From b8d258da82f425c88977c298ca5fc54c22541ed5 Mon Sep 17 00:00:00 2001 From: WIND <1652029918@qq.com> Date: Tue, 14 Jul 2026 17:28:15 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E7=9A=84=E8=B5=B7=E6=BA=90=E5=92=8C=E4=BC=A6=E6=95=A6=E9=9E=8B?= =?UTF-8?q?=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/bundles/reading-page.bundle.js | 87 +++++++++++++++++++++++++++---- js/runtime/unifiedReadingPage.js | 87 +++++++++++++++++++++++++++---- 2 files changed, 156 insertions(+), 18 deletions(-) diff --git a/js/bundles/reading-page.bundle.js b/js/bundles/reading-page.bundle.js index ed5d31c0..8e8891ce 100644 --- a/js/bundles/reading-page.bundle.js +++ b/js/bundles/reading-page.bundle.js @@ -5320,7 +5320,7 @@ const checkboxGroups = getCheckboxAnswers(); checkboxGroups.forEach((values, name) => { - const questionIds = expandQuestionSequence(name); + const questionIds = resolveCheckboxQuestionIds(name); if (!questionIds.length) { return; } @@ -5355,6 +5355,26 @@ return answers; } + function resolveCheckboxQuestionIds(name) { + const questionIds = expandQuestionSequence(name); + if (questionIds.length <= 1) { + return questionIds; + } + const firstQuestionId = questionIds[0]; + const answerKey = state.dataset?.answerKey || {}; + const questionGroup = buildQuestionGroupLookup(state.dataset).get(firstQuestionId) || null; + if ( + questionGroup + && questionGroup.kind === 'multi_choice' + && Array.isArray(questionGroup.questionIds) + && questionGroup.questionIds.length === 1 + && Array.isArray(answerKey[firstQuestionId]) + ) { + return [firstQuestionId]; + } + return questionIds; + } + function normalizeAnswerValue(value) { if (Array.isArray(value)) { return splitAnswerTokens(value); @@ -5490,10 +5510,16 @@ : splitAnswerTokens(value); const normalized = []; rawTokens.forEach((entry) => { - const token = canonicalizeAnswerToken(entry); + const rawChoiceToken = String(entry ?? '').trim().toUpperCase(); + const token = /^[A-Z]$/.test(rawChoiceToken) + ? rawChoiceToken + : canonicalizeAnswerToken(entry); if (!token) { return; } + if (!/^[A-Z]$/.test(token)) { + return; + } if (!normalized.some((existing) => areAnswerTokensEquivalent(existing, token))) { normalized.push(token); } @@ -5513,6 +5539,46 @@ return tokens.sort((left, right) => left.localeCompare(right, 'en')); } + function resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, targetQuestionId) { + const questionIds = Array.isArray(questionGroup?.questionIds) + ? questionGroup.questionIds.map((entry) => normalizeQuestionId(entry)).filter(Boolean) + : []; + const selectedTokens = collectGroupChoiceTokens(answers, questionIds); + const remainingTokens = selectedTokens.slice(); + const assignments = new Map(); + + questionIds.forEach((questionId) => { + const expectedToken = canonicalizeAnswerToken(answerKey[questionId]); + if (!expectedToken) { + return; + } + const matchedIndex = remainingTokens.findIndex((token) => areAnswerTokensEquivalent(token, expectedToken)); + if (matchedIndex >= 0) { + assignments.set(questionId, remainingTokens[matchedIndex]); + remainingTokens.splice(matchedIndex, 1); + } + }); + + questionIds.forEach((questionId) => { + if (assignments.has(questionId)) { + return; + } + const fallbackToken = remainingTokens.shift(); + if (fallbackToken) { + assignments.set(questionId, fallbackToken); + } + }); + + const normalizedTargetId = normalizeQuestionId(targetQuestionId) || targetQuestionId; + const expectedToken = canonicalizeAnswerToken(answerKey[normalizedTargetId]); + const assignedToken = assignments.get(normalizedTargetId) || ''; + return { + displayUserAnswer: assignedToken || answers[normalizedTargetId] || '', + expectedToken, + isCorrect: Boolean(assignedToken && expectedToken && areAnswerTokensEquivalent(assignedToken, expectedToken)) + }; + } + function questionWeight(correctAnswer, questionGroup = null) { if (Array.isArray(correctAnswer)) { const normalized = normalizeAnswerValue(correctAnswer); @@ -5573,14 +5639,13 @@ let partialCorrectCount = isCorrect ? weight : 0; if (isSplitMultiChoiceGroup) { - const selectedTokens = collectGroupChoiceTokens(answers, questionGroup.questionIds); - const expectedToken = canonicalizeAnswerToken(correctAnswer); - displayUserAnswer = selectedTokens.length ? selectedTokens : userAnswer; - if (!expectedToken) { + const splitSelection = resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, normalizedQuestionId); + displayUserAnswer = splitSelection.displayUserAnswer || userAnswer; + if (!splitSelection.expectedToken) { isCorrect = null; partialCorrectCount = 0; } else { - isCorrect = selectedTokens.some((token) => areAnswerTokensEquivalent(token, expectedToken)); + isCorrect = splitSelection.isCorrect; partialCorrectCount = isCorrect ? 1 : 0; } weight = 1; @@ -5665,13 +5730,17 @@ const label = escapeHtml(displayLabel(entry.questionId)); const userAnswer = escapeHtml(displayAnswerValue(entry.userAnswer)); const correctAnswer = escapeHtml(displayAnswerValue(entry.correctAnswer, '')); - const status = entry.isCorrect ? '✓' : '✗'; + const partial = Number(entry.partialCorrectCount) || 0; + const weight = Number(entry.weight) || 1; + const isPartial = !entry.isCorrect && partial > 0 && weight > 1; + const status = entry.isCorrect ? '✓' : (isPartial ? `${partial}/${weight}` : '✗'); + const statusClass = entry.isCorrect ? 'result-correct' : (isPartial ? 'result-partial' : 'result-incorrect'); return ` ${label} ${userAnswer} ${correctAnswer || ''} - ${status} + ${status} `; }).join(''); diff --git a/js/runtime/unifiedReadingPage.js b/js/runtime/unifiedReadingPage.js index 09c7cdfe..4faacf4a 100644 --- a/js/runtime/unifiedReadingPage.js +++ b/js/runtime/unifiedReadingPage.js @@ -3384,7 +3384,7 @@ const checkboxGroups = getCheckboxAnswers(); checkboxGroups.forEach((values, name) => { - const questionIds = expandQuestionSequence(name); + const questionIds = resolveCheckboxQuestionIds(name); if (!questionIds.length) { return; } @@ -3419,6 +3419,26 @@ return answers; } + function resolveCheckboxQuestionIds(name) { + const questionIds = expandQuestionSequence(name); + if (questionIds.length <= 1) { + return questionIds; + } + const firstQuestionId = questionIds[0]; + const answerKey = state.dataset?.answerKey || {}; + const questionGroup = buildQuestionGroupLookup(state.dataset).get(firstQuestionId) || null; + if ( + questionGroup + && questionGroup.kind === 'multi_choice' + && Array.isArray(questionGroup.questionIds) + && questionGroup.questionIds.length === 1 + && Array.isArray(answerKey[firstQuestionId]) + ) { + return [firstQuestionId]; + } + return questionIds; + } + function normalizeAnswerValue(value) { if (Array.isArray(value)) { return splitAnswerTokens(value); @@ -3554,10 +3574,16 @@ : splitAnswerTokens(value); const normalized = []; rawTokens.forEach((entry) => { - const token = canonicalizeAnswerToken(entry); + const rawChoiceToken = String(entry ?? '').trim().toUpperCase(); + const token = /^[A-Z]$/.test(rawChoiceToken) + ? rawChoiceToken + : canonicalizeAnswerToken(entry); if (!token) { return; } + if (!/^[A-Z]$/.test(token)) { + return; + } if (!normalized.some((existing) => areAnswerTokensEquivalent(existing, token))) { normalized.push(token); } @@ -3577,6 +3603,46 @@ return tokens.sort((left, right) => left.localeCompare(right, 'en')); } + function resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, targetQuestionId) { + const questionIds = Array.isArray(questionGroup?.questionIds) + ? questionGroup.questionIds.map((entry) => normalizeQuestionId(entry)).filter(Boolean) + : []; + const selectedTokens = collectGroupChoiceTokens(answers, questionIds); + const remainingTokens = selectedTokens.slice(); + const assignments = new Map(); + + questionIds.forEach((questionId) => { + const expectedToken = canonicalizeAnswerToken(answerKey[questionId]); + if (!expectedToken) { + return; + } + const matchedIndex = remainingTokens.findIndex((token) => areAnswerTokensEquivalent(token, expectedToken)); + if (matchedIndex >= 0) { + assignments.set(questionId, remainingTokens[matchedIndex]); + remainingTokens.splice(matchedIndex, 1); + } + }); + + questionIds.forEach((questionId) => { + if (assignments.has(questionId)) { + return; + } + const fallbackToken = remainingTokens.shift(); + if (fallbackToken) { + assignments.set(questionId, fallbackToken); + } + }); + + const normalizedTargetId = normalizeQuestionId(targetQuestionId) || targetQuestionId; + const expectedToken = canonicalizeAnswerToken(answerKey[normalizedTargetId]); + const assignedToken = assignments.get(normalizedTargetId) || ''; + return { + displayUserAnswer: assignedToken || answers[normalizedTargetId] || '', + expectedToken, + isCorrect: Boolean(assignedToken && expectedToken && areAnswerTokensEquivalent(assignedToken, expectedToken)) + }; + } + function questionWeight(correctAnswer, questionGroup = null) { if (Array.isArray(correctAnswer)) { const normalized = normalizeAnswerValue(correctAnswer); @@ -3637,14 +3703,13 @@ let partialCorrectCount = isCorrect ? weight : 0; if (isSplitMultiChoiceGroup) { - const selectedTokens = collectGroupChoiceTokens(answers, questionGroup.questionIds); - const expectedToken = canonicalizeAnswerToken(correctAnswer); - displayUserAnswer = selectedTokens.length ? selectedTokens : userAnswer; - if (!expectedToken) { + const splitSelection = resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, normalizedQuestionId); + displayUserAnswer = splitSelection.displayUserAnswer || userAnswer; + if (!splitSelection.expectedToken) { isCorrect = null; partialCorrectCount = 0; } else { - isCorrect = selectedTokens.some((token) => areAnswerTokensEquivalent(token, expectedToken)); + isCorrect = splitSelection.isCorrect; partialCorrectCount = isCorrect ? 1 : 0; } weight = 1; @@ -3729,13 +3794,17 @@ const label = escapeHtml(displayLabel(entry.questionId)); const userAnswer = escapeHtml(displayAnswerValue(entry.userAnswer)); const correctAnswer = escapeHtml(displayAnswerValue(entry.correctAnswer, '')); - const status = entry.isCorrect ? '✓' : '✗'; + const partial = Number(entry.partialCorrectCount) || 0; + const weight = Number(entry.weight) || 1; + const isPartial = !entry.isCorrect && partial > 0 && weight > 1; + const status = entry.isCorrect ? '✓' : (isPartial ? `${partial}/${weight}` : '✗'); + const statusClass = entry.isCorrect ? 'result-correct' : (isPartial ? 'result-partial' : 'result-incorrect'); return ` ${label} ${userAnswer} ${correctAnswer || ''} - ${status} + ${status} `; }).join(''); From ebbb3f47bfe51c88392d648e56d4cc78b358640a Mon Sep 17 00:00:00 2001 From: WIND <1652029918@qq.com> Date: Fri, 17 Jul 2026 20:47:30 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=A4=E6=98=93?= =?UTF-8?q?=E7=9A=84=E6=9C=AC=E8=83=BD=E7=AC=AC25=E9=A2=98=E7=AD=94?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/generated/reading-exams/p2-low-49.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/generated/reading-exams/p2-low-49.js b/assets/generated/reading-exams/p2-low-49.js index 44816373..dbb82178 100644 --- a/assets/generated/reading-exams/p2-low-49.js +++ b/assets/generated/reading-exams/p2-low-49.js @@ -75,7 +75,7 @@ "q9": "I", "q10": "F", "q11": "A", - "q12": "D", + "q12": "J", "q13": "H" }, "sourceRefs": { From ae5ccba9c435cbdcc114486877c278f332f894e4 Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Sat, 18 Jul 2026 13:27:30 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E6=97=B6=E5=B0=9A?= =?UTF-8?q?=E4=BA=A7=E4=B8=9A=E9=98=85=E8=AF=BB=E6=AE=B5=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/generated/reading-exams/p2-low-142.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/generated/reading-exams/p2-low-142.js b/assets/generated/reading-exams/p2-low-142.js index e7a1ffbb..42bdaef3 100644 --- a/assets/generated/reading-exams/p2-low-142.js +++ b/assets/generated/reading-exams/p2-low-142.js @@ -20,7 +20,7 @@ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 2

\n

You should spend about 20 minutes on Questions 14–26, which are based on Reading Passage 2 below.

\n \n

The fashion industry

\n \n

A The fashion industry is a multibillion-dollar global enterprise devoted to the business of making and selling clothes. It encompasses all types of garments, from designer fashions to ordinary everyday clothing. Because data on the industry are typically reported for national economies and expressed in terms of its many separate sectors, total figures for world production of textiles and clothing are difficult to obtain. However, by any measure, the industry accounts for a significant share of world economic output.

\n

B The fashion industry is a product of the modern age. Prior to the mid-19th century, virtually all clothing was handmade for individuals, either as home production or on order from dressmakers and tailors. By the beginning of the 20th century, with the development of new technologies such as the sewing machine, the factory system of production, and the growth of department stores and other retail outlets, clothing had increasingly come to be mass-produced in standard sizes and sold at fixed prices. Although the fashion industry developed first in Europe, today it is highly globalised, with garments often designed in one country, manufactured in another, and sold in a third. For example, an American fashion company might source fabric in China, have the clothes manufactured in Vietnam, finished in Italy, and shipped to a warehouse in the United States for distribution to retail outlets internationally.

\n

C One of the first accomplishments of the Industrial Revolution in the 18th century was the partial automation of the spinning and weaving of wool, cotton, silk and other natural fibres. Today, these processes are highly automated and carried out by computer-controlled, high-speed machinery, and fabrics made from both natural fibres and synthetic fibres (such as nylon, acrylic and polyester) are produced. A growing interest in sustainable fashion (or ‘eco-fashion') has led to greater use of environmentally friendly fibres, such as hemp. In addition, high-tech synthetic fabrics confer such properties as moisture absorption, stain resistance, retention or dissipation of body heat, and protection against fire, weapons, cold, ultraviolet radiation and other hazards. Fabrics are also produced with a wide range of visual effects through dyeing, weaving, printing and other processes. Together with fashion forecasters, fabric manufacturers work well in advance of the clothing production cycle to create fabrics with colours, textures and other qualities that anticipate consumer demand.

\n

D Historically, very few fashion designers have become famous—brands such as Coco Chanel or Calvin Klein—who have been responsible for prestigious high-fashion collections. These designers are influential in the fashion world, but, contrary to popular belief, they do not dictate new fashions; rather, they endeavour to design clothes that will meet consumer demand. The vast majority of designers work anonymously for manufacturers, as part of design teams, adapting designs into marketable garments for average consumers. They draw inspiration from a wide range of sources, including film and television costumes, street clothing and active sportswear.

\n

E An important stage in garment production is the translation of the clothing design into templates, in a range of sizes, for cutting the cloth. Because the proportions of the human body change with increases or decreases in weight, templates cannot simply be scaled up or down. Template-making was traditionally a highly skilled profession. Today, despite innovations in computer programming, designs in larger sizes are difficult to adjust for every body shape. Whatever the size, the template—whether drawn on paper or programmed as a set of computer instructions—determines how fabric is cut into the pieces that will be joined to make a garment. For all but the most expensive clothing, fabric cutting is accomplished by computer-guided knives or high-intensity lasers that can cut many layers of fabric at once.

\n

F The next stage of production is the assembly process. Some companies use their own production facilities for some or all of the manufacturing process, but the majority rely on separately owned manufacturing firms or contractors to produce garments to their specifications. In the field of women's clothing, manufacturers typically produce several product lines a year, which they deliver to retailers on predetermined dates. Technological innovation, including the development of computer-guided machinery, has resulted in the automation of some stages of assembly. Nevertheless, the fundamental process of sewing remains labour-intensive. In the late 20th century, China emerged as the world's largest producer of clothing because of its low labour costs and highly disciplined workforce. Assembled items then go through various processes collectively known as 'finishing'. These include the addition of decorative elements, fasteners, brand-name labels and other labels (often legally required) specifying fibre content, laundry instructions and country of manufacture. Finished items are then pressed and packed for shipment.

\n

G For much of the period following World War II, trade in textiles and garments was strictly regulated by purchasing countries, which imposed quotas and tariffs. Since the 1980s, these protectionist measures, which were intended (ultimately without success) to prevent textile and clothing production from moving from high-wage to low-wage countries, have gradually been abandoned. They have been replaced by a free-trade approach, under the regulatory control of global organisations. The advent of metal shipping containers and relatively inexpensive air freight has also made it possible for production to be closely tied to market conditions, even across globe-spanning distances.

\n \n
\n \n \n
" + "html": "

READING PASSAGE 2

\n

You should spend about 20 minutes on Questions 14–26, which are based on Reading Passage 2 below.

\n \n

The fashion industry

\n \n

A The fashion industry is a multibillion-dollar global enterprise devoted to the business of making and selling clothes. It encompasses all types of garments, from designer fashions to ordinary everyday clothing. Because data on the industry are typically reported for national economies and expressed in terms of its many separate sectors, total figures for world production of textiles and clothing are difficult to obtain. However, by any measure, the industry accounts for a significant share of world economic output.

\n

B The fashion industry is a product of the modern age. Prior to the mid-19th century, virtually all clothing was handmade for individuals, either as home production or on order from dressmakers and tailors. By the beginning of the 20th century, with the development of new technologies such as the sewing machine, the factory system of production, and the growth of department stores and other retail outlets, clothing had increasingly come to be mass-produced in standard sizes and sold at fixed prices. Although the fashion industry developed first in Europe, today it is highly globalised, with garments often designed in one country, manufactured in another, and sold in a third. For example, an American fashion company might source fabric in China, have the clothes manufactured in Vietnam, finished in Italy, and shipped to a warehouse in the United States for distribution to retail outlets internationally.

\n

C One of the first accomplishments of the Industrial Revolution in the 18th century was the partial automation of the spinning and weaving of wool, cotton, silk and other natural fibres. Today, these processes are highly automated and carried out by computer-controlled, high-speed machinery, and fabrics made from both natural fibres and synthetic fibres (such as nylon, acrylic and polyester) are produced. A growing interest in sustainable fashion (or ‘eco-fashion') has led to greater use of environmentally friendly fibres, such as hemp. In addition, high-tech synthetic fabrics confer such properties as moisture absorption, stain resistance, retention or dissipation of body heat, and protection against fire, weapons, cold, ultraviolet radiation and other hazards. Fabrics are also produced with a wide range of visual effects through dyeing, weaving, printing and other processes. Together with fashion forecasters, fabric manufacturers work well in advance of the clothing production cycle to create fabrics with colours, textures and other qualities that anticipate consumer demand.

\n

D Historically, very few fashion designers have become famous—brands such as Coco Chanel or Calvin Klein—who have been responsible for prestigious high-fashion collections. These designers are influential in the fashion world, but, contrary to popular belief, they do not dictate new fashions; rather, they endeavour to design clothes that will meet consumer demand. The vast majority of designers work anonymously for manufacturers, as part of design teams, adapting designs into marketable garments for average consumers. They draw inspiration from a wide range of sources, including film and television costumes, street clothing and active sportswear.

\n

The fashion industry’s traditional design methods, such as paper sketches and the draping of fabric on mannequins, have been supplemented or replaced by computer-assisted design techniques. These allow designers to rapidly make changes to a proposed design and instantaneously share the proposed changes with colleagues—whether they are in the next room or on another continent.

\n

E An important stage in garment production is the translation of the clothing design into templates, in a range of sizes, for cutting the cloth. Because the proportions of the human body change with increases or decreases in weight, templates cannot simply be scaled up or down. Template-making was traditionally a highly skilled profession. Today, despite innovations in computer programming, designs in larger sizes are difficult to adjust for every body shape. Whatever the size, the template—whether drawn on paper or programmed as a set of computer instructions—determines how fabric is cut into the pieces that will be joined to make a garment. For all but the most expensive clothing, fabric cutting is accomplished by computer-guided knives or high-intensity lasers that can cut many layers of fabric at once.

\n

F The next stage of production is the assembly process. Some companies use their own production facilities for some or all of the manufacturing process, but the majority rely on separately owned manufacturing firms or contractors to produce garments to their specifications. In the field of women's clothing, manufacturers typically produce several product lines a year, which they deliver to retailers on predetermined dates. Technological innovation, including the development of computer-guided machinery, has resulted in the automation of some stages of assembly. Nevertheless, the fundamental process of sewing remains labour-intensive. In the late 20th century, China emerged as the world's largest producer of clothing because of its low labour costs and highly disciplined workforce. Assembled items then go through various processes collectively known as 'finishing'. These include the addition of decorative elements, fasteners, brand-name labels and other labels (often legally required) specifying fibre content, laundry instructions and country of manufacture. Finished items are then pressed and packed for shipment.

\n

G For much of the period following World War II, trade in textiles and garments was strictly regulated by purchasing countries, which imposed quotas and tariffs. Since the 1980s, these protectionist measures, which were intended (ultimately without success) to prevent textile and clothing production from moving from high-wage to low-wage countries, have gradually been abandoned. They have been replaced by a free-trade approach, under the regulatory control of global organisations. The advent of metal shipping containers and relatively inexpensive air freight has also made it possible for production to be closely tied to market conditions, even across globe-spanning distances.

\n \n
\n \n \n
" } ] }, From 75200075e25c6a8dd3bedf7254f82b2b3c94eb1e Mon Sep 17 00:00:00 2001 From: WIND <1652029918@qq.com> Date: Sun, 19 Jul 2026 16:04:58 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8DKatherine=20Mansfield?= =?UTF-8?q?=E7=AC=AC6=E9=A2=98=E7=AD=94=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/generated/reading-exams/p1-high-05.js | 8 ++++---- assets/generated/reading-explanations/p1-high-05.js | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/assets/generated/reading-exams/p1-high-05.js b/assets/generated/reading-exams/p1-high-05.js index 1a90b1ff..9fb4096f 100644 --- a/assets/generated/reading-exams/p1-high-05.js +++ b/assets/generated/reading-exams/p1-high-05.js @@ -20,7 +20,7 @@ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n \n

Katherine Mansfield

\n

Katherine Mansfield was a modernist writer of short fiction who was born and brought up in New Zealand.

\n \n

Katherine Mansfield Beauchamp Murry was born in 1888, into a prominent family in Wellington, New Zealand. She became one of New Zealand’s best-known writers, using the pen name of Katherine Mansfield. The daughter of a banker, and born into a middle-class family, she was also a first cousin of Countess Elizabeth von Arnim, a distinguished novelist in her time. Mansfield had two older sisters and a younger brother. Her father, Harold Beauchamp, went on to become the chairman of the Bank of New Zealand. In 1893, the Mansfield family moved to Karori, a suburb of Wellington, where Mansfield would spend the happiest years of her childhood; she later used her memories of this time as an inspiration for her Prelude story.

\n \n

Her first published stories appeared in the High School Reporter and the Wellington Girls’ High School magazine in 1898 and 1899. In 1902, she developed strong feelings for a musician who played the cello, Arnold Trowell, although her feelings were not, for the most part, returned. Mansfield herself was an accomplished cellist, having received lessons from Trowell’s father. Mansfield wrote in her journals of feeling isolated to some extent in New Zealand, and, in general terms, of her interest in the Maori people (New Zealand’s native people), who were often portrayed in a sympathetic light in her later stories, such as How Pearl Button Was Kidnapped.

\n\n

She moved to London in 1903, where she attended Queen’s College, along with her two sisters. Mansfield recommenced playing the cello, an occupation that she believed, during her time at Queen’s, she would take up professionally. She also began contributing to the college newspaper, with such a dedication to it that she eventually became its editor. She was particularly interested in the works of the French writers of this period and in the 19th-century British writer, Oscar Wilde, and she was appreciated amongst fellow students at Queen’s for her lively and charismatic approach to life and work. She met fellow writer Ida Baker, a South African, at the college, and the pair became lifelong friends. Mansfield did not actively support the suffragette movement in the UK. Women in New Zealand had gained the right to vote in 1893.

\n\n

Mansfield first began journeying into other parts of Europe in the period 1903–1906, mainly to Belgium and Germany. After finishing her schooling in England, she returned to her New Zealand home in 1906, only then beginning to write short stories in a serious way. She had several works published in Australia in a magazine called The Native Companion, which was her first paid writing work, and by this time she had her mind set on becoming a professional writer. It was also the first occasion on which she used the pseudonym “K. Mansfield”.

\n\n

Mansfield rapidly grew discontented with the provincial New Zealand lifestyle, and with her family. Two years later she headed again to London. Her father sent her an annual subsidy of £100 for the rest of her life. In later years, she would express both admiration and disdain for New Zealand in her journals.

\n\n

In 1911, Mansfield met John Middleton Murry, the Oxford scholar and editor of the literary magazine Rhythm. They were later to marry in 1918. Mansfield became a co-editor of Rhythm, which was subsequently called The Blue Review, in which more of her works were published. She and Murry lived in various houses in England and briefly in Paris. The Blue Review failed to gain enough readers and was no longer published. Their attempt to set up as writers in Paris was cut short by Murry’s bankruptcy, which resulted from the failure of this and other journals. Life back in England meant frequently changed addresses and very limited funds.

\n\n

Between 1915 and 1918, Mansfield moved between England and Bandol, France. She and Murry developed close contact with other well-known writers of the time such as D. H. Lawrence, Bertrand Russell and Aldous Huxley. By October 1918 Mansfield had become seriously ill; she had been diagnosed with tuberculosis and was advised to enter a sanatorium. She could no longer spend winters in London. In the autumn of 1918 she was so ill that she decided to go to Ospedaletti in Italy. It was the publication of Bliss and Other Stories in 1920 that was to solidify Mansfield’s reputation as a writer.

\n\n

Mansfield also spent time in Menton, France, as the tenant of her father’s cousin at “The Villa Isola Bella”. There she wrote eight stories including Miss Brill and The Daughters of the Late Colonel, the latter of which she pronounced to be “…the only story that satisfies me to any extent”.

\n\n

Mansfield produced a great deal of work in the final years of her life, and much of her prose and poetry remained unpublished at her death in 1923. After her death, her husband, Murry, took on the task of editing and publishing her works. His efforts resulted in two additional volumes of short stories, The Doves’ Nest and Something Childish, published in 1923 and 1924 respectively; the publication of her Poems; as well as a collection of critical writings (Novels and Novelists) and a number of editions of Mansfield’s previously unpublished letters and journals.

\n \n
\n \n \n
" + "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n \n

Katherine Mansfield

\n

Katherine Mansfield was a modernist writer of short fiction who was born and brought up in New Zealand.

\n \n

Katherine Mansfield Beauchamp Murry was born in 1888, into a prominent family in Wellington, New Zealand. She became one of New Zealand’s best-known writers, using the pen name of Katherine Mansfield. The daughter of a banker, and born into a middle-class family, she was also a first cousin of Countess Elizabeth von Arnim, a distinguished novelist in her time. Mansfield had two older sisters and a younger brother. Her father, Harold Beauchamp, went on to become the chairman of the Bank of New Zealand. In 1893 the Mansfield family moved to Karori, a suburb of Wellington, where Mansfield would spend the happiest years of her childhood; she later used her memories of this time as an inspiration for her Prelude story.

\n \n

Her first published stories appeared in the High School Reporter and the Wellington Girls’ High School magazine in 1898 and 1899. In 1902 she developed strong feelings for a musician who played the cello, Arnold Trowell, although her feelings were not, for the most part, returned. Mansfield herself was an accomplished cellist, having received lessons from Trowell’s father. Mansfield wrote in her journals of feeling isolated to some extent in New Zealand, and, in general terms, of her interest in the Maori people (New Zealand’s native people), who were often portrayed in a sympathetic light in her later stories, such as How Pearl Button Was Kidnapped.

\n\n

She moved to London in 1903, where she attended Queen’s College, along with her two sisters. Mansfield recommenced playing the cello, an occupation that she believed, during her time at Queen’s, she would take up professionally. She also began contributing to the college newspaper, with such a dedication to it that she eventually became its editor. She was particularly interested in the works of the French writers of this period and in the 19th-century British writer, Oscar Wilde, and she was appreciated amongst fellow students at Queen’s for her lively and charismatic approach to life and work. She met fellow writer Ida Baker, a South African, at the college, and the pair became lifelong friends. Mansfield did not become involved in much political activity when she lived in London. For example, although at that time in London many women were demonstrating for votes for women, Mansfield did not actively support the suffragette movement in the UK. Women in New Zealand had gained the right to vote in 1893.

\n\n

Mansfield first began journeying into other parts of Europe in the period 1903–1906, mainly to Belgium and Germany. After finishing her schooling in England, she returned to her New Zealand home in 1906, only then beginning to write short stories in a serious way. She had several works published in Australia in a magazine called Native Companion, which was her first paid writing work, and by this time she had her mind set on becoming a professional writer. It was also the first occasion on which she used the pseudonym ‘K. Mansfield’.

\n\n

Mansfield rapidly grew discontented with the provincial New Zealand lifestyle, and with her family. Two years later she headed again for London. Her father sent her an annual subsidy of £100 for the rest of her life. In later years, she would express both admiration and disdain for New Zealand in her journals.

\n\n

In 1911, Mansfield met John Middleton Murry, the Oxford scholar and editor of the literary magazine Rhythm. They were later to marry, in 1918. Mansfield became a co-editor of Rhythm, which was subsequently called The Blue Review, in which more of her works were published. She and Murry lived in various houses in England and briefly in Paris. The Blue Review failed to gain enough readers and was no longer published. Their attempt to set up as writers in Paris was cut short by Murry’s bankruptcy, which resulted from the failure of this and other journals. Life back in England meant frequently changed addresses and very limited funds.

\n\n

Between 1915 and 1918, Mansfield moved between England and Bandol, France. She and Murry developed close contact with other well-known writers of the time such as D.H. Lawrence, Bertrand Russell and Aldous Huxley. By October 1918 Mansfield had become seriously ill; she had been diagnosed with tuberculosis, and was advised to enter a sanatorium. She could no longer spend winters in London. In the autumn of 1918 she was so ill that she decided to go to Ospedaletti in Italy. It was the publication of Bliss and Other Stories in 1920 that was to solidify Mansfield’s reputation as a writer.

\n\n

Mansfield also spent time in Menton, France, as the tenant of her father’s cousin at ‘The Villa Isola Bella’. There she wrote eight stories including Miss Brill and The Daughters of the Late Colonel, the latter of which she pronounced to be ‘…the only story that satisfies me to any extent’.

\n\n

Mansfield produced a great deal of work in the final years of her life, and much of her prose and poetry remained unpublished at her death in 1923. After her death her husband, Murry, took on the task of editing and publishing her works. His efforts resulted in two additional volumes of short stories, The Dove’s Nest and Something Childish, published in 1923 and 1924 respectively, the publication of her Poems, as well as a collection of critical writings (Novels and Novelists) and a number of editions of Mansfield’s previously unpublished letters and journals.

\n \n
\n \n \n
" } ] }, @@ -36,7 +36,7 @@ "q5", "q6" ], - "bodyHtml": "
\n

Questions 1–6

\n

Do the following statements agree with the information given in Reading Passage 1?

\n

In boxes 1–6 on your answer sheet, write:

\n \n \n
\n

1. The name Katherine Mansfield, which appears on the writer’s books, was exactly the same as her original name.

\n
\n \n \n \n
\n
\n \n
\n

2. Mansfield won a prize for a story she wrote for the High School Reporter.

\n
\n \n \n \n
\n
\n \n
\n

3. How Pearl Button Was Kidnapped portrayed Maori people in a favourable way.

\n
\n \n \n \n
\n
\n \n
\n

4. When Mansfield was at Queen’s College, she planned to be a professional writer.

\n
\n \n \n \n
\n
\n \n
\n

5. Mansfield was unpopular with the other students at Queen’s College.

\n
\n \n \n \n
\n
\n \n
\n

6. In London, Mansfield showed little interest in politics.

\n
\n \n \n \n
\n
\n
", + "bodyHtml": "
\n

Questions 1–6

\n

Do the following statements agree with the information given in Reading Passage 1?

\n

In boxes 1–6 on your answer sheet, write:

\n \n \n
\n

1. The name Katherine Mansfield, that appears on the writer’s books, was exactly the same as her original name.

\n
\n \n \n \n
\n
\n \n
\n

2. Mansfield won a prize for a story she wrote for the High School Reporter.

\n
\n \n \n \n
\n
\n \n
\n

3. How Pearl Button Was Kidnapped portrayed Maori people in a favourable way.

\n
\n \n \n \n
\n
\n \n
\n

4. When Mansfield was at Queen’s College, she planned to be a professional writer.

\n
\n \n \n \n
\n
\n \n
\n

5. Mansfield was unpopular with the other students at Queen’s College.

\n
\n \n \n \n
\n
\n \n
\n

6. In London, Mansfield showed little interest in politics.

\n
\n \n \n \n
\n
\n
", "leadHtml": "

Questions

" }, { @@ -51,7 +51,7 @@ "q12", "q13" ], - "bodyHtml": "
\n

Questions 7–13

\n

Complete the notes below

\n

Choose ONE WORD AND/OR A NUMBER from the passage for each answer

\n

Write your answers in boxes 7–13 on your answer sheet

\n \n
\n

Katherine Mansfield’s adult years

\n


– moved from England back to New Zealand

\n

– first paid writing work was in a publication based in 8

\n

– her 9 and the New Zealand way of life made her feel dissatisfied

\n \n

• 1908
– returned to London

\n \n

• 1911–1919
– Met John Middleton Murry in 1911

\n

– 10 prevented Mansfield and Murry from staying together in Paris

\n

– spent time with distinguished 11

\n

– from 1916, tuberculosis restricted the time she spent in London

\n\n

• 1920
– her 12 was consolidated when Bliss and Other Stories was published

\n

– wrote several stories at “Villa Isola Bella”

\n\n

• 1923–1924
– Mansfield’s 13 published more of her works after her death

\n
\n
" + "bodyHtml": "
\n

Questions 7–13

\n

Complete the notes below

\n

Choose ONE WORD AND/OR A NUMBER from the passage for each answer

\n

Write your answers in boxes 7–13 on your answer sheet

\n \n
\n

Katherine Mansfield’s adult years

\n


– moved from England back to New Zealand

\n

– first paid writing work was in a publication based in 8

\n

– her 9 and the New Zealand way of life made her feel dissatisfied

\n \n

• 1908
– returned to London

\n \n

• 1911–1919
– met John Middleton Murry in 1911

\n

– 10 prevented Mansfield and Murry from staying longer in Paris

\n

– spent time with distinguished 11

\n

– from 1918, tuberculosis restricted the time she spent in London

\n\n

• 1920
– her 12 was consolidated when Bliss and Other Stories was published

\n

– wrote several stories at ‘The Villa Isola Bella’

\n\n

• 1923–1924
– Mansfield’s 13 published more of her works after her death

\n
\n
" } ], "answerKey": { @@ -60,7 +60,7 @@ "q3": "TRUE", "q4": "FALSE", "q5": "FALSE", - "q6": "NOT GIVEN", + "q6": "TRUE", "q7": "1906", "q8": "Australia", "q9": "family", diff --git a/assets/generated/reading-explanations/p1-high-05.js b/assets/generated/reading-explanations/p1-high-05.js index e75ccd8c..4a3e3804 100644 --- a/assets/generated/reading-explanations/p1-high-05.js +++ b/assets/generated/reading-explanations/p1-high-05.js @@ -25,7 +25,7 @@ }, { "label": "第三段", - "text": "1903年,她和两个姐姐一起搬到伦敦,就读于女王学院。曼斯菲尔德重新开始拉大提琴,这是她在女王学院期间相信自己会专业从事的职业。她也开始为学院报纸撰稿,并对此投入了极大的热情,最终成为了报纸的编辑。她对这个时期的法国作家和19世纪英国作家奥斯卡·王尔德的作品特别感兴趣,并且因其活泼、有魅力的人生态度和工作方式而受到女王学院同学们的赞赏。她在学院结识了来自南非的作家艾达·贝克,两人成为了一生的朋友。曼斯菲尔德并未积极支持英国的妇女参政运动。新西兰的妇女在1893年就获得了投票权。" + "text": "1903年,她和两个姐姐一起搬到伦敦,就读于女王学院。曼斯菲尔德重新开始拉大提琴,这是她在女王学院期间相信自己会专业从事的职业。她也开始为学院报纸撰稿,并对此投入了极大的热情,最终成为了报纸的编辑。她对这个时期的法国作家和19世纪英国作家奥斯卡·王尔德的作品特别感兴趣,并且因其活泼、有魅力的人生态度和工作方式而受到女王学院同学们的赞赏。她在学院结识了来自南非的作家艾达·贝克,两人成为了一生的朋友。曼斯菲尔德在伦敦生活时并没有参与太多政治活动。例如,当时伦敦有许多女性为女性投票权示威,但曼斯菲尔德并未积极支持英国的妇女参政运动。新西兰的妇女在1893年就获得了投票权。" }, { "label": "第四段", @@ -84,7 +84,7 @@ }, { "questionNumber": 6, - "text": "(6) 题目 6:In London, Mansfield showed little interest in politics. (在伦敦,曼斯菲尔德对政治表现出很少的兴趣。)\n答案:NOT GIVEN\n解析:第三段只提到她没有积极支持妇女参政运动,但这不足以推断她对所有政治都“兴趣不大”。", + "text": "(6) 题目 6:In London, Mansfield showed little interest in politics. (在伦敦,曼斯菲尔德对政治表现出很少的兴趣。)\n答案:TRUE\n解析:第三段明确提到“Mansfield did not become involved in much political activity when she lived in London”,即她在伦敦生活时没有参与太多政治活动,与题目意思一致。", "questionId": "q6" } ], @@ -92,7 +92,7 @@ "start": 1, "end": 6 }, - "text": "答案:FALSE\n解析:第一段明确指出“Katherine Mansfield”是她的笔名(pen name),而她的原名是“Katherine Mansfield Beauchamp Murry”,两者并不完全相同。\n答案:NOT GIVEN\n解析:第二段只提到她的故事“appeared in”(发表在)该杂志上,并未提及是否获奖。\n答案:TRUE\n解析:第二段提到毛利人“were often portrayed in a sympathetic light in her later stories”,即以同情的眼光描绘,这与“favourable way”(有利的方式)意思相符。\n答案:FALSE\n解析:第三段说她在女王学院时,相信自己会专业从事大提琴演奏(take up professionally);而第四段指出,她是在回到新西兰后才下定决心成为职业作家的。\n答案:FALSE\n解析:第三段说她因其“lively and charismatic approach”而受到同学们的“appreciated”(欣赏),与“unpopular”(不受欢迎)相反。\n答案:NOT GIVEN\n解析:第三段只提到她没有积极支持妇女参政运动,但这不足以推断她对所有政治都“兴趣不大”。" + "text": "答案:FALSE\n解析:第一段明确指出“Katherine Mansfield”是她的笔名(pen name),而她的原名是“Katherine Mansfield Beauchamp Murry”,两者并不完全相同。\n答案:NOT GIVEN\n解析:第二段只提到她的故事“appeared in”(发表在)该杂志上,并未提及是否获奖。\n答案:TRUE\n解析:第二段提到毛利人“were often portrayed in a sympathetic light in her later stories”,即以同情的眼光描绘,这与“favourable way”(有利的方式)意思相符。\n答案:FALSE\n解析:第三段说她在女王学院时,相信自己会专业从事大提琴演奏(take up professionally);而第四段指出,她是在回到新西兰后才下定决心成为职业作家的。\n答案:FALSE\n解析:第三段说她因其“lively and charismatic approach”而受到同学们的“appreciated”(欣赏),与“unpopular”(不受欢迎)相反。\n答案:TRUE\n解析:第三段明确提到“Mansfield did not become involved in much political activity when she lived in London”,即她在伦敦生活时没有参与太多政治活动,与题目意思一致。" }, { "sectionTitle": "2. 笔记填空(Questions 7–13)", @@ -105,7 +105,7 @@ }, { "questionNumber": 8, - "text": "(2) 题目 8:first paid writing work was in a publication based in \\___\\___ (第一次有报酬的写作工作是在一份总部位于_\\___\\__的出版物上)\n答案:Australia\n解析:第四段提到她“published in Australia in a magazine called The Native Companion, which was her first paid writing work”。", + "text": "(2) 题目 8:first paid writing work was in a publication based in \\___\\___ (第一次有报酬的写作工作是在一份总部位于_\\___\\__的出版物上)\n答案:Australia\n解析:第四段提到她“published in Australia in a magazine called Native Companion, which was her first paid writing work”。", "questionId": "q8" }, { @@ -115,7 +115,7 @@ }, { "questionNumber": 10, - "text": "(4) 题目 10:\\___\\___ prevented Mansfield and Murry from staying together in Paris (\\___\\___阻止了曼斯菲尔德和穆里在巴黎待在一起)\n答案:bankruptcy\n解析:第六段提到他们留在巴黎的尝试因穆里的“bankruptcy”(破产)而中断。", + "text": "(4) 题目 10:\\___\\___ prevented Mansfield and Murry from staying longer in Paris (\\___\\___阻止了曼斯菲尔德和穆里在巴黎待更久)\n答案:bankruptcy\n解析:第六段提到他们留在巴黎的尝试因穆里的“bankruptcy”(破产)而中断。", "questionId": "q10" }, { From 9bb5dac17596f576e0cdd8c22a70b6697a70fd5f Mon Sep 17 00:00:00 2001 From: WIND <1652029918@qq.com> Date: Fri, 24 Jul 2026 15:07:17 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=8A=E6=AF=9B?= =?UTF-8?q?=E4=BA=A7=E4=B8=9A=E7=9A=84=E5=8E=86=E5=8F=B2=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reading-explanations/p1-high-194.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/assets/generated/reading-explanations/p1-high-194.js b/assets/generated/reading-explanations/p1-high-194.js index 167604be..2a58760d 100644 --- a/assets/generated/reading-explanations/p1-high-194.js +++ b/assets/generated/reading-explanations/p1-high-194.js @@ -25,27 +25,27 @@ }, { "label": "Paragraph 3", - "text": "1331年,国王爱德华三世鼓励来自佛兰德(今比利时地区)的熟练织工到英格兰定居。这些佛兰德织工及其后代在英格兰布料的最终发展中发挥了作用。原毛出口贸易得以恢复,十四世纪上半叶是该产业日益繁荣的时期。随后,1347年至1350年间,欧洲爆发了一场严重的腺鼠疫疫情,这场疫情因贸易中断而严重打击了羊毛产业,且优先级也随之改变。然而,在英格兰,大规模的养羊业从十四世纪中叶起反而有所增长。" + "text": "1331年,国王爱德华三世鼓励来自佛兰德(今比利时地区)的熟练织工到英格兰定居。这些佛兰德织工及其后代推动了英格兰布料的发展。原毛出口贸易恢复,十四世纪上半叶英格兰养羊农户较为繁荣;但长期对法战争和1349年的黑死病造成严重影响,许多村庄人口大量死亡。由于劳动力不足,土地无法继续大量种植农作物,羊群数量反而增加。" }, { "label": "Paragraph 4", - "text": "十五世纪,英格兰从原毛出口国转变为羊毛布料的制造国和出口国。对外国客户而言,从英格兰购买羊毛布料比购买原毛并在自己国内支付可观的布料加工成本更为便宜。英格兰的西郡和东盎格利亚地区——最初是通过普利茅斯和金斯林港口从事原毛贸易的中心——开始生产羊毛布料;随后,英格兰其他地区也加入其中。" + "text": "尽管经历挫折,原毛出口继续扩大,羊毛织物制造也随之发展,并逐渐呈现专业化和地域化。西郡拥有大片牧羊草地、可用于洗毛和染色的软水,以及可驱动机器的水力;约克郡和兰开夏的丘陵地区也有软水和急流,可为缩绒磨坊提供动力。" }, { "label": "Paragraph 5", - "text": "十六世纪期间,许多欧洲织工为逃避宗教迫害而移民英格兰。除了这些新工人的技艺之外,导致布料生产增长的其他因素还包括水力资源的便利获取(因为英格兰东部大部分地区地势平坦,水力可以用于驱动生产布料的机器)。该产业的进步还得益于英格兰境内丰富的煤炭供应。" + "text": "东盎格利亚有软水,但缺少丘陵和急流,不能为缩绒磨坊提供动力。当地利用本地绵羊产出的长而细的羊毛,生产一种不需要缩绒工艺的布料,也就是后来以 Worstead 村命名的 worsted(精纺毛布)。东盎格利亚凭借1331年佛兰德移民传承下来的技术,主导精纺毛布贸易长达四百年。" }, { "label": "Paragraph 6", - "text": "羊毛布料的生产直到十八世纪末工业革命之前,始终是英格兰最重要的产业。到十七世纪末,羊毛产品约占英格兰出口总额的三分之二。1730年至1740年间,一种新型羊毛布料被开发出来:此后,“精纺毛布”的产量不断增加。精纺毛布在英格兰北部的约克郡得到了特别的发展,直至十八世纪末,约克郡的五个主要制造业城镇生产了全英格兰55%的精纺毛布。" + "text": "英国羊毛布料很快获得国际声誉。十四、十五世纪,英格兰从以出口原毛为主转向制造并出口布料;十五世纪末,英格兰被认为很大程度上是一个由养羊农户和布料制造者组成的国家。十六世纪,受宗教迫害的法国织工来到英格兰并带来技术;到十七世纪末,羊毛制造业已占英格兰出口价值的三分之二。到1770年,约克郡的精纺毛布产量追平东盎格利亚,利兹、布拉德福德等主要城镇推动了当地布料制造区的发展。" }, { "label": "Paragraph 7", - "text": "1750年以后,羊毛产业发生了巨大变化。机器——最初用于生产棉布(比生产羊毛简单得多),随后越来越多地用于羊毛——被发明出来。机器的引入给羊毛产业带来了危机:1820年至1830年间,英格兰手工业男女工匠的人数减少了一半以上。1812年,抗议者在约克郡的利兹镇摧毁了机器。尽管如此,机械化的发展意味着十九世纪成为羊毛生产的第二个伟大时期。在约克郡,由于廉价煤炭的可获得性——这是蒸汽驱动机械所必需的——扩张尤为显著。" + "text": "1750年至1850年的工业革命带来了新的变化。源自兰开夏棉纺织业的新发明使纺纱和织布过程机械化并大幅加速,长期未变的生产方法被取代。机械化曾遭到反对,1812年的大规模动乱导致抗议者破坏设备,但最终机器仍然取代了旧方式。" }, { "label": "Paragraph 8", - "text": "另一方面,机器使用的增加导致了羊毛布料制造的专业化程度提高。约克郡专门生产一种布料——精纺毛布;苏格兰专门生产另一种——粗花呢;而西英格兰则专门生产第三种——地毯。因此,到1900年,英格兰的布料产业已与两百年前具有完全不同的特征。" + "text": "十九世纪期间,东盎格利亚等老工业区永久衰落,约克郡因更容易接受机械化而赶超并保持领先。约克郡的发展得到廉价煤炭供应的支持,这些煤炭可用于蒸汽以及后来的电力。其他地区形成专门化生产:苏格兰以粗花呢闻名,西郡则专注生产高质量机织地毯。" } ], "questionExplanations": [ @@ -70,12 +70,12 @@ }, { "questionNumber": 4, - "text": "(4)题目 4:An outbreak of bubonic plague led to a sharp fall in sheep numbers.\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “there was a serious epidemic of bubonic plague... which hit the wool industry badly... In England, however, large-scale sheep farming increased from the mid-fourteenth century onwards”。原文明确说鼠疫严重打击了羊毛产业,但在英格兰,大规模的养羊业反而从十四世纪中叶起有所增长。绵羊数量并未急剧下降,反而增加了,因此题干与原文矛盾,答案为 FALSE。", + "text": "(4)题目 4:An outbreak of bubonic plague led to a sharp fall in sheep numbers.\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “bubonic plague (the Black Death), which in 1349 caused devastation” 以及 “This led to an increase of the sheep flocks”。原文说明黑死病造成大量人口死亡,但其结果是羊群数量增加,因为剩余劳动力不足以继续耕种农作物。因此题干所说“绵羊数量急剧下降”与原文相反,答案为 FALSE。", "questionId": "q4" }, { "questionNumber": 5, - "text": "(5)题目 5:Worsted cloth was cheaper to produce than other types of woollen fabric.\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 6–8,原文多次提及精纺毛布(worsted),但只描述了它的开发时间(1730–1740年间)、产地(约克郡)和专业化发展,未在任何地方将其生产成本与其他类型的羊毛布料进行比较,因此题干信息在文中未被提及,答案为 NOT GIVEN。", + "text": "(5)题目 5:Worsted cloth was cheaper to produce than other types of woollen fabric.\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 5 中 “produce a cloth which did not require the fulling process” 以及 Paragraph 6 中 “output of worsted from Yorkshire equalled that of East Anglia”。原文说明精纺毛布不需要缩绒工艺,并提到约克郡产量追平东盎格利亚,但没有比较精纺毛布与其他羊毛布料的生产成本,因此答案为 NOT GIVEN。", "questionId": "q5" } ], @@ -83,7 +83,7 @@ "start": 1, "end": 5 }, - "text": "题目翻译:将羊毛制成布料的工艺是由罗马人传入英国的。\n答案:FALSE\n解析:定位 Paragraph 1 中 “By the time the Romans invaded in 55 BC the Britons had developed a wool industry”。原文明确说明在公元前55年罗马人入侵时,不列颠人已经发展出了羊毛产业,也就是说,在罗马人到来之前,不列颠人已经掌握了将羊毛制成布料的工艺,而非罗马人传入的,因此题干与原文矛盾,答案为 FALSE。\n题目翻译:在十二世纪,出口羊毛布料不如出口原毛利润高。\n答案:TRUE\n解析:定位 Paragraph 1 中 “By the twelfth century... cloth making was widespread... But the greatest wealth came from exports of raw wool”。原文指出在十二世纪,虽然布料制作已普及,但最大的财富来自原毛出口。这说明原毛出口比布料出口更赚钱,即布料出口不如原毛出口利润高,与题干表述一致,答案为 TRUE。\n题目翻译:统治者对羊毛产业的成功有经济利益。\n答案:TRUE\n解析:定位 Paragraph 2 中 “Kings and their ministers welcomed the revenue that resulted from exports and export taxes”。国王及其大臣们乐于接受出口和出口税所带来的财政收入,这说明统治者的收入直接来自羊毛产业的出口收益,他们对羊毛产业的成功有着明确的经济利益,与题干完全一致,答案为 TRUE。\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “there was a serious epidemic of bubonic plague... which hit the wool industry badly... In England, however, large-scale sheep farming increased from the mid-fourteenth century onwards”。原文明确说鼠疫严重打击了羊毛产业,但在英格兰,大规模的养羊业反而从十四世纪中叶起有所增长。绵羊数量并未急剧下降,反而增加了,因此题干与原文矛盾,答案为 FALSE。\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 6–8,原文多次提及精纺毛布(worsted),但只描述了它的开发时间(1730–1740年间)、产地(约克郡)和专业化发展,未在任何地方将其生产成本与其他类型的羊毛布料进行比较,因此题干信息在文中未被提及,答案为 NOT GIVEN。" + "text": "题目翻译:将羊毛制成布料的工艺是由罗马人传入英国的。\n答案:FALSE\n解析:定位 Paragraph 1 中 “By the time the Romans invaded in 55 BC the Britons had developed a wool industry”。原文明确说明在公元前55年罗马人入侵时,不列颠人已经发展出了羊毛产业,也就是说,在罗马人到来之前,不列颠人已经掌握了将羊毛制成布料的工艺,而非罗马人传入的,因此题干与原文矛盾,答案为 FALSE。\n题目翻译:在十二世纪,出口羊毛布料不如出口原毛利润高。\n答案:TRUE\n解析:定位 Paragraph 1 中 “By the twelfth century... cloth making was widespread... But the greatest wealth came from exports of raw wool”。原文指出在十二世纪,虽然布料制作已普及,但最大的财富来自原毛出口。这说明原毛出口比布料出口更赚钱,即布料出口不如原毛出口利润高,与题干表述一致,答案为 TRUE。\n题目翻译:统治者对羊毛产业的成功有经济利益。\n答案:TRUE\n解析:定位 Paragraph 2 中 “Kings and their ministers welcomed the revenue that resulted from exports and export taxes”。国王及其大臣们乐于接受出口和出口税所带来的财政收入,这说明统治者的收入直接来自羊毛产业的出口收益,他们对羊毛产业的成功有着明确的经济利益,与题干完全一致,答案为 TRUE。\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “bubonic plague (the Black Death), which in 1349 caused devastation” 以及 “This led to an increase of the sheep flocks”。原文说明黑死病造成大量人口死亡,但其结果是羊群数量增加,因为剩余劳动力不足以继续耕种农作物。因此题干所说“绵羊数量急剧下降”与原文相反,答案为 FALSE。\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 5 中 “produce a cloth which did not require the fulling process” 以及 Paragraph 6 中 “output of worsted from Yorkshire equalled that of East Anglia”。原文说明精纺毛布不需要缩绒工艺,并提到约克郡产量追平东盎格利亚,但没有比较精纺毛布与其他羊毛布料的生产成本,因此答案为 NOT GIVEN。" }, { "sectionTitle": "2. 笔记填空题(Questions 6–13)", @@ -91,42 +91,42 @@ "items": [ { "questionNumber": 6, - "text": "(1)题目 6:16th century: skilled ________ emigrated to England\n题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 5 中 “During the sixteenth century, many European weavers fled from religious persecution and emigrated to England”。十六世纪,许多欧洲织工为逃避宗教迫害而移民英格兰,与题干时间(16世纪)、事件(emigrated to England)完全对应,修饰词 “skilled” 对应原文中 “weavers” 的职业特性,答案为 weavers。", + "text": "(1)题目 6:16th century: skilled ________ emigrated to England\n题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 6 中 “In the sixteenth century, French weavers, persecuted for their Protestant religion, sought refuge in England and took their skills with them”。原文说明十六世纪法国织工因宗教迫害来到英格兰,并把技术带了过去;题干中的 skilled 对应 “took their skills with them”,空格应填职业 weavers。", "questionId": "q6" }, { "questionNumber": 7, - "text": "(2)题目 7:end 17th century: majority of English ________ were wool products\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “By the late seventeenth century woollen products accounted for about two-thirds of English exports”。到十七世纪末,羊毛产品占英国出口总额的三分之二,即英国出口的大部分是羊毛产品。题干中 “majority” 对应原文的 “about two-thirds”,答案为 exports。", + "text": "(2)题目 7:end 17th century: majority of English ________ were wool products\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “by the end of the seventeenth century it comprised two-thirds of the value of its exports”。这里 it 指前文的羊毛制造业,说明到十七世纪末,羊毛产品占英格兰出口价值的三分之二,即英国出口的大部分是羊毛产品,答案为 exports。", "questionId": "q7" }, { "questionNumber": 8, - "text": "(3)题目 8:18th century: production of worsted cloth increased in Yorkshire – growth of five key manufacturing ________\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “until the end of the eighteenth century the five main manufacturing towns in Yorkshire produced 55% of all English worsteds”。十八世纪末前,约克郡五个主要制造业城镇生产了全英格兰55%的精纺毛布,与题干中 “five key manufacturing ________” 完全对应,答案为 towns。", + "text": "(3)题目 8:18th century: production of worsted cloth increased in Yorkshire – growth of five key manufacturing ________\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “By 1770, output of worsted from Yorkshire equalled that of East Anglia” 以及 “with the expansion of major towns: Leeds, Bradford, Halifax, Huddersfield, and Wakefield”。1770年属于十八世纪,原文说明约克郡精纺毛布产量增长,并列出五个主要城镇,因此答案为 towns。", "questionId": "q8" }, { "questionNumber": 9, - "text": "(4)题目 9:1750–1850: new machinery was developed – initially for the production of ________\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “Machines - first for producing cotton (much simpler than producing wool), then increasingly for wool – were invented”。机器最初用于生产棉布,之后才越来越多地用于羊毛生产,与题干中 “initially for the production of” 完全对应,答案为 cotton。", + "text": "(4)题目 9:1750–1850: new machinery was developed – initially for the production of ________\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “new inventions stemming from the Lancashire cotton industry, to mechanize and speed dramatically the processes of spinning and weaving”。原文说明工业革命带来的新发明源自兰开夏棉纺织业,之后才推动纺纱和织布机械化;题干问这些新机械最初用于生产什么,答案为 cotton。", "questionId": "q9" }, { "questionNumber": 10, - "text": "(5)题目 10:1812: protests resulted in the ________ of machinery\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “Machinery was destroyed by protesters in the Yorkshire town of Leeds in 1812”。1812年,抗议者在约克郡的利兹镇摧毁了机器,与题干时间(1812)、事件(protests resulted in...)完全对应,抗议导致的结果是机器的 destruction(摧毁),答案为 destruction。", + "text": "(5)题目 10:1812: protests resulted in the ________ of machinery\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “The widespread unrest of 1812 led to the destruction of equipment by bands of rioters”。原文说明1812年的大规模动乱导致暴动者破坏设备;题干中的 machinery 对应 equipment,空格应填 destruction。", "questionId": "q10" }, { "questionNumber": 11, - "text": "(6)题目 11:19th century: in Yorkshire mechanisation increased, aided by the availability of cheap ________\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 7 中 “In Yorkshire, the expansion was particularly great because of the availability of cheap coal, which was necessary for the steam-driven machines”。约克郡的扩张尤为显著,原因在于廉价煤炭的可获得性——这是蒸汽驱动机械所必需的,与题干中 “availability of cheap ________” 完全对应,答案为 coal。", + "text": "(6)题目 11:19th century: in Yorkshire mechanisation increased, aided by the availability of cheap ________\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 8 中 “They were overtaken by Yorkshire, where machinery was more readily accepted” 以及 “supported by abundant supplies of inexpensive coal”。原文说明约克郡更容易接受机械化,并受到充足廉价煤炭供应的支持;cheap 对应 inexpensive,答案为 coal。", "questionId": "q11" }, { "questionNumber": 12, - "text": "(7)题目 12:Scotland – specialised in ________\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Scotland specialised in another type – tweeds”。苏格兰专门生产粗花呢,与题干 “Scotland – specialised in” 完全对应,答案为 tweeds。", + "text": "(7)题目 12:Scotland – specialised in ________\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Other specialised types of manufacturing developed in Scotland, famed for its tweeds”。原文说明苏格兰发展出专门化制造,并以 tweeds 闻名,因此答案为 tweeds。", "questionId": "q12" }, { "questionNumber": 13, - "text": "(8)题目 13:West Country – specialised in ________\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West of England in a third type – carpets”。西英格兰专门生产地毯,与题干 “West Country – specialised in” 完全对应,答案为 carpets。", + "text": "(8)题目 13:West Country – specialised in ________\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West Country, which focused on the production of high-quality, woven carpets”。原文说明西郡专注生产高质量机织地毯,因此答案为 carpets。", "questionId": "q13" } ], @@ -134,7 +134,7 @@ "start": 6, "end": 13 }, - "text": "题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 5 中 “During the sixteenth century, many European weavers fled from religious persecution and emigrated to England”。十六世纪,许多欧洲织工为逃避宗教迫害而移民英格兰,与题干时间(16世纪)、事件(emigrated to England)完全对应,修饰词 “skilled” 对应原文中 “weavers” 的职业特性,答案为 weavers。\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “By the late seventeenth century woollen products accounted for about two-thirds of English exports”。到十七世纪末,羊毛产品占英国出口总额的三分之二,即英国出口的大部分是羊毛产品。题干中 “majority” 对应原文的 “about two-thirds”,答案为 exports。\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “until the end of the eighteenth century the five main manufacturing towns in Yorkshire produced 55% of all English worsteds”。十八世纪末前,约克郡五个主要制造业城镇生产了全英格兰55%的精纺毛布,与题干中 “five key manufacturing ________” 完全对应,答案为 towns。\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “Machines - first for producing cotton (much simpler than producing wool), then increasingly for wool – were invented”。机器最初用于生产棉布,之后才越来越多地用于羊毛生产,与题干中 “initially for the production of” 完全对应,答案为 cotton。\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “Machinery was destroyed by protesters in the Yorkshire town of Leeds in 1812”。1812年,抗议者在约克郡的利兹镇摧毁了机器,与题干时间(1812)、事件(protests resulted in...)完全对应,抗议导致的结果是机器的 destruction(摧毁),答案为 destruction。\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 7 中 “In Yorkshire, the expansion was particularly great because of the availability of cheap coal, which was necessary for the steam-driven machines”。约克郡的扩张尤为显著,原因在于廉价煤炭的可获得性——这是蒸汽驱动机械所必需的,与题干中 “availability of cheap ________” 完全对应,答案为 coal。\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Scotland specialised in another type – tweeds”。苏格兰专门生产粗花呢,与题干 “Scotland – specialised in” 完全对应,答案为 tweeds。\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West of England in a third type – carpets”。西英格兰专门生产地毯,与题干 “West Country – specialised in” 完全对应,答案为 carpets。" + "text": "题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 6 中 “In the sixteenth century, French weavers, persecuted for their Protestant religion, sought refuge in England and took their skills with them”。原文说明十六世纪法国织工因宗教迫害来到英格兰,并把技术带了过去;题干中的 skilled 对应 “took their skills with them”,空格应填职业 weavers。\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “by the end of the seventeenth century it comprised two-thirds of the value of its exports”。这里 it 指前文的羊毛制造业,说明到十七世纪末,羊毛产品占英格兰出口价值的三分之二,即英国出口的大部分是羊毛产品,答案为 exports。\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “By 1770, output of worsted from Yorkshire equalled that of East Anglia” 以及 “with the expansion of major towns: Leeds, Bradford, Halifax, Huddersfield, and Wakefield”。1770年属于十八世纪,原文说明约克郡精纺毛布产量增长,并列出五个主要城镇,因此答案为 towns。\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “new inventions stemming from the Lancashire cotton industry, to mechanize and speed dramatically the processes of spinning and weaving”。原文说明工业革命带来的新发明源自兰开夏棉纺织业,之后才推动纺纱和织布机械化;题干问这些新机械最初用于生产什么,答案为 cotton。\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “The widespread unrest of 1812 led to the destruction of equipment by bands of rioters”。原文说明1812年的大规模动乱导致暴动者破坏设备;题干中的 machinery 对应 equipment,空格应填 destruction。\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 8 中 “They were overtaken by Yorkshire, where machinery was more readily accepted” 以及 “supported by abundant supplies of inexpensive coal”。原文说明约克郡更容易接受机械化,并受到充足廉价煤炭供应的支持;cheap 对应 inexpensive,答案为 coal。\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Other specialised types of manufacturing developed in Scotland, famed for its tweeds”。原文说明苏格兰发展出专门化制造,并以 tweeds 闻名,因此答案为 tweeds。\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West Country, which focused on the production of high-quality, woven carpets”。原文说明西郡专注生产高质量机织地毯,因此答案为 carpets。" } ] } From 22fb4c8241179d62735a73fa966e9518f467dd70 Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Sun, 2 Aug 2026 22:21:20 +0800 Subject: [PATCH 06/18] =?UTF-8?q?chore(reading):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=98=E5=BA=93=20frequency=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=B8=BA=E5=85=AB=E6=9C=88=E9=AB=98=E9=A2=91=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 依据「八月高频表格(2).xlsx」的「八月高频文章」本月频次更新 85 条 frequency - A 组(在八月表内不一致)更新为八月本月频次;B 组按「全部文章」表频率;C 组(疑似过度标注)改为 low - examId 保持不变;同步重建 core-foundation.bundle.js --- assets/generated/reading-exams/manifest.js | 170 ++++++++++----------- js/bundles/core-foundation.bundle.js | 170 ++++++++++----------- 2 files changed, 170 insertions(+), 170 deletions(-) diff --git a/assets/generated/reading-exams/manifest.js b/assets/generated/reading-exams/manifest.js index b38a1cdf..713207d8 100644 --- a/assets/generated/reading-exams/manifest.js +++ b/assets/generated/reading-exams/manifest.js @@ -41,7 +41,7 @@ "script": "./p3-high-03.js", "title": "What makes a musical expert_ 音乐天赋", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", @@ -56,7 +56,7 @@ "script": "./p3-high-04.js", "title": "Yawning 打呵欠", "category": "P3", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", "filename": "101. P3 - Yawning 打呵欠【高】.html", @@ -86,7 +86,7 @@ "script": "./p2-low-06.js", "title": "Biomimicry 仿生学", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", "filename": "103. P2 - Biomimicry 仿生学.html", @@ -101,7 +101,7 @@ "script": "./p3-low-07.js", "title": "Star Performers 明星员工", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", "filename": "104. P3 - Star Performers 明星员工.html", @@ -131,7 +131,7 @@ "script": "./p2-high-09.js", "title": "Early Approaches to Organisational Design 组织设计", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", @@ -191,7 +191,7 @@ "script": "./p1-low-13.js", "title": "Report on a university drama project 大学戏剧项目报告", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", @@ -206,7 +206,7 @@ "script": "./p2-high-14.js", "title": "Should space be explored by robots or by humans 人机太空探索", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", @@ -221,7 +221,7 @@ "script": "./p3-high-15.js", "title": "Whale Culture 鲸鱼文化", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", @@ -281,7 +281,7 @@ "script": "./p2-high-19.js", "title": "Mind Music 脑海中的音乐(心灵音乐)", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", @@ -311,7 +311,7 @@ "script": "./p2-high-21.js", "title": "Stress Less 工作压力", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", "filename": "117. P2 - Stress Less 工作压力【高】.html", @@ -326,7 +326,7 @@ "script": "./p3-medium-22.js", "title": "Neanderthal Technology 尼安德特人的生存技艺", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", @@ -341,7 +341,7 @@ "script": "./p2-high-23.js", "title": "The Constant Evolution of the Humble Tomato 番茄的演化", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", @@ -371,7 +371,7 @@ "script": "./p2-high-25.js", "title": "Will Eating Less Make You Live Longer 节食与长寿", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", @@ -386,7 +386,7 @@ "script": "./p1-high-27.js", "title": "Footprints in the Mud 恐龙脚印", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", @@ -431,7 +431,7 @@ "script": "./p1-low-30.js", "title": "Investing in the Future 投资未来", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", "filename": "125. P1 - Investing in the Future 投资未来.html", @@ -476,7 +476,7 @@ "script": "./p1-medium-33.js", "title": "The Pyramid of Cestius 罗马金字塔", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", @@ -536,7 +536,7 @@ "script": "./p2-low-37.js", "title": "Keeping the water away 洪水防控", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", "filename": "131. P2 - Keeping the water away 洪水防控.html", @@ -611,7 +611,7 @@ "script": "./p3-low-42.js", "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", "category": "P3", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 4.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", @@ -626,7 +626,7 @@ "script": "./p3-low-43.js", "title": "What is social history 社会史", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", "filename": "137. P3 - What is social history 社会史.html", @@ -656,7 +656,7 @@ "script": "./p1-low-45.js", "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", @@ -671,7 +671,7 @@ "script": "./p1-low-46.js", "title": "Sydney Opera House 悉尼歌剧院", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", @@ -701,7 +701,7 @@ "script": "./p1-low-48.js", "title": "The history of the guitar 吉他的历史", "category": "P1", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", "filename": "141. P1 - The history of the guitar 吉他的历史.html", @@ -731,7 +731,7 @@ "script": "./p2-low-50.js", "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", @@ -761,7 +761,7 @@ "script": "./p1-low-52.js", "title": "Caral an ancient South American city 卡拉尔古城", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", @@ -791,7 +791,7 @@ "script": "./p3-low-54.js", "title": "Movement Underwater 水下运动", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", "filename": "147. P3 - Movement Underwater 水下运动.html", @@ -1001,7 +1001,7 @@ "script": "./p1-low-68.js", "title": "The Clipper Races 帆船竞速", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", "filename": "16. P1 - The Clipper Races 帆船竞速.html", @@ -1031,7 +1031,7 @@ "script": "./p1-low-70.js", "title": "Fluorescence Deep sea discovery深海发光生物研究", "category": "P1", - "frequency": "次高频", + "frequency": "low", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", @@ -1106,7 +1106,7 @@ "script": "./p2-low-75.js", "title": "Lean Production Innovation 精益生产", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", "filename": "166. P2 - Lean Production Innovation 精益生产.html", @@ -1136,7 +1136,7 @@ "script": "./p2-low-77.js", "title": "Mammoth Kill 猛犸象的灭绝", "category": "P2", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", @@ -1181,7 +1181,7 @@ "script": "./p1-low-80.js", "title": "The unsung sense 被低估的嗅觉", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", @@ -1211,7 +1211,7 @@ "script": "./p1-high-82.js", "title": "Think Small 微观科学", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", "filename": "172. P1 - Think Small 微观科学.html", @@ -1241,7 +1241,7 @@ "script": "./p1-low-84.js", "title": "Why good ideas fail TF公司", "category": "P1", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", "filename": "174. P1 - Why good ideas fail TF公司.html", @@ -1271,7 +1271,7 @@ "script": "./p2-medium-86.js", "title": "Urban Regeneration 柏林公园改造", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", @@ -1346,7 +1346,7 @@ "script": "./p2-high-91.js", "title": "Australia’s camouflaged creatures 澳洲伪装生物", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", @@ -1361,7 +1361,7 @@ "script": "./p1-high-92.js", "title": "Dust and the American West 美国西部尘埃", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", @@ -1376,7 +1376,7 @@ "script": "./p2-medium-93.js", "title": "Antarctic research 南极考察", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", "filename": "182. P2 - Antarctic research 南极考察.html", @@ -1406,7 +1406,7 @@ "script": "./p3-low-95.js", "title": "The strange world of sight 奇异的视觉世界", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", @@ -1466,7 +1466,7 @@ "script": "./p1-low-99.js", "title": "The history of the bar code 条形码的历史", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", "filename": "188. P1 - The history of the bar code 条形码的历史.html", @@ -1526,7 +1526,7 @@ "script": "./p2-low-103.js", "title": "The economic effect of climate 气候对经济的影响", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", @@ -1541,7 +1541,7 @@ "script": "./p2-low-104.js", "title": "1115纸笔Should we stop eating meat 是否应该吃素", "category": "P2", - "frequency": "高频", + "frequency": "low", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", @@ -1586,7 +1586,7 @@ "script": "./p1-low-107.js", "title": "The life of Beatrix Potter 彼得兔作家", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", @@ -1616,7 +1616,7 @@ "script": "./p1-low-109.js", "title": "The Origin of Paper 造纸术起源", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", "filename": "23. P1 - The Origin of Paper 造纸术起源.html", @@ -1676,7 +1676,7 @@ "script": "./p1-low-113.js", "title": "Thomas Young The last man who knew everything 托马斯·杨", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", @@ -1691,7 +1691,7 @@ "script": "./p1-low-114.js", "title": "Triumph of the City 城市的胜利", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 1.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", "filename": "28. P1 - Triumph of the City 城市的胜利.html", @@ -1736,7 +1736,7 @@ "script": "./p1-medium-117.js", "title": "What Lucy Taught Us 露西化石", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", @@ -1766,7 +1766,7 @@ "script": "./p1-medium-119.js", "title": "Wood 新西兰木材产业", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 2, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", "filename": "32. P1 - Wood 新西兰木材产业【次】.html", @@ -1811,7 +1811,7 @@ "script": "./p2-low-122.js", "title": "Biophilic Design 亲自然设计", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", "filename": "35. P2 - Biophilic Design 亲自然设计.html", @@ -1841,7 +1841,7 @@ "script": "./p2-high-124.js", "title": "Corporate Social Responsibility 企业社会责任", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", @@ -1886,7 +1886,7 @@ "script": "./p1-low-127.js", "title": "Ambergris 龙涎香", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", "filename": "4. P1 - Ambergris 龙涎香.html", @@ -1916,7 +1916,7 @@ "script": "./p2-medium-129.js", "title": "Intelligent behaviour in birds 鸟类智慧行为", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", @@ -1931,7 +1931,7 @@ "script": "./p2-high-130.js", "title": "Investment in shares versus investment in other assets 回报数据分析", "category": "P2", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", @@ -2006,7 +2006,7 @@ "script": "./p2-low-135.js", "title": "Skyscraper Farming 摩天大楼种植", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", @@ -2036,7 +2036,7 @@ "script": "./p2-high-137.js", "title": "Surviving city life 动物适应城市", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", @@ -2066,7 +2066,7 @@ "script": "./p2-high-139.js", "title": "The conquest of malaria in Italy 意大利疟疾防治", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", @@ -2111,7 +2111,7 @@ "script": "./p2-low-142.js", "title": "The fashion industry 时尚产业", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", "filename": "53. P2 - The fashion industry 时尚产业.html", @@ -2126,7 +2126,7 @@ "script": "./p2-low-143.js", "title": "The impact of invasive species 入侵物种的影响", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", @@ -2156,7 +2156,7 @@ "script": "./p2-high-145.js", "title": "The return of monkey life 猴群回归", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", @@ -2186,7 +2186,7 @@ "script": "./p2-low-147.js", "title": "Who wrote Shakespeare's plays 莎士比亚", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", @@ -2261,7 +2261,7 @@ "script": "./p3-medium-152.js", "title": "Charles Darwin and Evolutionary Psychology 进化心理学", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", @@ -2306,7 +2306,7 @@ "script": "./p3-medium-155.js", "title": "Does class size matter_ 课堂规模", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", @@ -2336,7 +2336,7 @@ "script": "./p3-high-157.js", "title": "Flower Power 鲜花的力量(花之力)", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", @@ -2411,7 +2411,7 @@ "script": "./p3-medium-162.js", "title": "Jean Piaget (1896–1980) 让·皮亚杰", "category": "P3", - "frequency": "高频", + "frequency": "low", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", @@ -2471,7 +2471,7 @@ "script": "./p3-low-166.js", "title": "Life on Mars_ 火星地球化改造", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", @@ -2501,7 +2501,7 @@ "script": "./p3-medium-168.js", "title": "Marketing and the information age 信息时代营销", "category": "P3", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", @@ -2516,7 +2516,7 @@ "script": "./p3-medium-169.js", "title": "(无题目) Music Language We All Speak 音乐语言", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4.5, "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", @@ -2621,7 +2621,7 @@ "script": "./p3-medium-176.js", "title": "The Analysis of Fear 猴子恐惧实验", "category": "P3", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", @@ -2711,7 +2711,7 @@ "script": "./p1-medium-182.js", "title": "Listening to the Ocean 海洋探测", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", @@ -2726,7 +2726,7 @@ "script": "./p3-medium-183.js", "title": "The hazards of multitasking 多任务处理", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", @@ -2771,7 +2771,7 @@ "script": "./p3-low-186.js", "title": "The Robbers Cave Study (山洞)群体行为实验", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", @@ -2981,7 +2981,7 @@ "script": "./p2-medium-217.js", "title": "A mechanical friend for children 孩子的机器人朋友", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "三月/3.P2 高频/", "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", @@ -2996,7 +2996,7 @@ "script": "./p2-high-192.js", "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", "category": "P2", - "frequency": "高频", + "frequency": "low", "difficultyScore": 3.5, "path": "三月/4.P2 次高频/", "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", @@ -3011,7 +3011,7 @@ "script": "./p2-medium-209.js", "title": "Decision Fatigue 决策疲劳", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "三月/4.P2 次高频/", "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", @@ -3056,7 +3056,7 @@ "script": "./p2-medium-058.js", "title": "Who wrote Shakespeare's plays 莎士比亚", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "三月/4.P2 次高频/", "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", @@ -3116,7 +3116,7 @@ "script": "./p3-high-218.js", "title": "The Causes of Linguistic Change 语音的演变", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4.5, "path": "三月/5.P3 高频/", "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", @@ -3146,7 +3146,7 @@ "script": "./p3-low-999.js", "title": "Risk taking", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "三月/5.P3 高频/", "filename": "P3 - Risk taking.html", @@ -3161,7 +3161,7 @@ "script": "./p3-medium-197.js", "title": "Australia’s Megafauna Controversy 巨兽灭绝", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4.5, "path": "三月/6.P3 次高频/", "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", @@ -3191,7 +3191,7 @@ "script": "./p3-low-078.js", "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4.5, "path": "三月/6.P3 次高频/", "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", @@ -3206,7 +3206,7 @@ "script": "./p1-high-227.js", "title": "The Whale Goes to Court 鲸鱼油", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "ReadingPractice/PDF/", "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", @@ -3296,7 +3296,7 @@ "script": "./p2-high-232.js", "title": "The origin and development of applause 掌声的历史", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "ReadingPractice/PDF/", "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", @@ -3371,7 +3371,7 @@ "script": "./p2-high-236.js", "title": "War of the Plants 植物的战争", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": null, "path": "", "filename": "", @@ -3401,7 +3401,7 @@ "script": "./p2-high-239.js", "title": "Nanotechnology: the science of the very small 纳米科技", "category": "P2", - "frequency": "高频", + "frequency": "low", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", @@ -3416,7 +3416,7 @@ "script": "./p2-low-240.js", "title": "Coins - the first form of money 硬币起源", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "assets/generated/reading-exams/", "filename": "reading-practice-unified.html", @@ -3431,7 +3431,7 @@ "script": "./p1-high-240.js", "title": "The Origins of Weather Forecasting 天气预报", "category": "P1", - "frequency": "high", + "frequency": "高频", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", @@ -3446,7 +3446,7 @@ "script": "./p2-low-242.js", "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", "category": "P2", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", @@ -3476,7 +3476,7 @@ "script": "./p3-medium-241.js", "title": "Who looks after the children in today's Britain? 育儿分工", "category": "P3", - "frequency": "medium", + "frequency": "low", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "P3 - Who looks after the children in today's Britain.pdf", diff --git a/js/bundles/core-foundation.bundle.js b/js/bundles/core-foundation.bundle.js index 8504ee72..a18dbc22 100644 --- a/js/bundles/core-foundation.bundle.js +++ b/js/bundles/core-foundation.bundle.js @@ -9970,7 +9970,7 @@ storageManager.ready "script": "./p3-high-03.js", "title": "What makes a musical expert_ 音乐天赋", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", @@ -9985,7 +9985,7 @@ storageManager.ready "script": "./p3-high-04.js", "title": "Yawning 打呵欠", "category": "P3", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", "filename": "101. P3 - Yawning 打呵欠【高】.html", @@ -10015,7 +10015,7 @@ storageManager.ready "script": "./p2-low-06.js", "title": "Biomimicry 仿生学", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", "filename": "103. P2 - Biomimicry 仿生学.html", @@ -10030,7 +10030,7 @@ storageManager.ready "script": "./p3-low-07.js", "title": "Star Performers 明星员工", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", "filename": "104. P3 - Star Performers 明星员工.html", @@ -10060,7 +10060,7 @@ storageManager.ready "script": "./p2-high-09.js", "title": "Early Approaches to Organisational Design 组织设计", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", @@ -10120,7 +10120,7 @@ storageManager.ready "script": "./p1-low-13.js", "title": "Report on a university drama project 大学戏剧项目报告", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", @@ -10135,7 +10135,7 @@ storageManager.ready "script": "./p2-high-14.js", "title": "Should space be explored by robots or by humans 人机太空探索", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", @@ -10150,7 +10150,7 @@ storageManager.ready "script": "./p3-high-15.js", "title": "Whale Culture 鲸鱼文化", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", @@ -10210,7 +10210,7 @@ storageManager.ready "script": "./p2-high-19.js", "title": "Mind Music 脑海中的音乐(心灵音乐)", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", @@ -10240,7 +10240,7 @@ storageManager.ready "script": "./p2-high-21.js", "title": "Stress Less 工作压力", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", "filename": "117. P2 - Stress Less 工作压力【高】.html", @@ -10255,7 +10255,7 @@ storageManager.ready "script": "./p3-medium-22.js", "title": "Neanderthal Technology 尼安德特人的生存技艺", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", @@ -10270,7 +10270,7 @@ storageManager.ready "script": "./p2-high-23.js", "title": "The Constant Evolution of the Humble Tomato 番茄的演化", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", @@ -10300,7 +10300,7 @@ storageManager.ready "script": "./p2-high-25.js", "title": "Will Eating Less Make You Live Longer 节食与长寿", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", @@ -10315,7 +10315,7 @@ storageManager.ready "script": "./p1-high-27.js", "title": "Footprints in the Mud 恐龙脚印", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", @@ -10360,7 +10360,7 @@ storageManager.ready "script": "./p1-low-30.js", "title": "Investing in the Future 投资未来", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", "filename": "125. P1 - Investing in the Future 投资未来.html", @@ -10405,7 +10405,7 @@ storageManager.ready "script": "./p1-medium-33.js", "title": "The Pyramid of Cestius 罗马金字塔", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", @@ -10465,7 +10465,7 @@ storageManager.ready "script": "./p2-low-37.js", "title": "Keeping the water away 洪水防控", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", "filename": "131. P2 - Keeping the water away 洪水防控.html", @@ -10540,7 +10540,7 @@ storageManager.ready "script": "./p3-low-42.js", "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", "category": "P3", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 4.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", @@ -10555,7 +10555,7 @@ storageManager.ready "script": "./p3-low-43.js", "title": "What is social history 社会史", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", "filename": "137. P3 - What is social history 社会史.html", @@ -10585,7 +10585,7 @@ storageManager.ready "script": "./p1-low-45.js", "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", @@ -10600,7 +10600,7 @@ storageManager.ready "script": "./p1-low-46.js", "title": "Sydney Opera House 悉尼歌剧院", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", @@ -10630,7 +10630,7 @@ storageManager.ready "script": "./p1-low-48.js", "title": "The history of the guitar 吉他的历史", "category": "P1", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", "filename": "141. P1 - The history of the guitar 吉他的历史.html", @@ -10660,7 +10660,7 @@ storageManager.ready "script": "./p2-low-50.js", "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", @@ -10690,7 +10690,7 @@ storageManager.ready "script": "./p1-low-52.js", "title": "Caral an ancient South American city 卡拉尔古城", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", @@ -10720,7 +10720,7 @@ storageManager.ready "script": "./p3-low-54.js", "title": "Movement Underwater 水下运动", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", "filename": "147. P3 - Movement Underwater 水下运动.html", @@ -10930,7 +10930,7 @@ storageManager.ready "script": "./p1-low-68.js", "title": "The Clipper Races 帆船竞速", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", "filename": "16. P1 - The Clipper Races 帆船竞速.html", @@ -10960,7 +10960,7 @@ storageManager.ready "script": "./p1-low-70.js", "title": "Fluorescence Deep sea discovery深海发光生物研究", "category": "P1", - "frequency": "次高频", + "frequency": "low", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", @@ -11035,7 +11035,7 @@ storageManager.ready "script": "./p2-low-75.js", "title": "Lean Production Innovation 精益生产", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", "filename": "166. P2 - Lean Production Innovation 精益生产.html", @@ -11065,7 +11065,7 @@ storageManager.ready "script": "./p2-low-77.js", "title": "Mammoth Kill 猛犸象的灭绝", "category": "P2", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", @@ -11110,7 +11110,7 @@ storageManager.ready "script": "./p1-low-80.js", "title": "The unsung sense 被低估的嗅觉", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", @@ -11140,7 +11140,7 @@ storageManager.ready "script": "./p1-high-82.js", "title": "Think Small 微观科学", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", "filename": "172. P1 - Think Small 微观科学.html", @@ -11170,7 +11170,7 @@ storageManager.ready "script": "./p1-low-84.js", "title": "Why good ideas fail TF公司", "category": "P1", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", "filename": "174. P1 - Why good ideas fail TF公司.html", @@ -11200,7 +11200,7 @@ storageManager.ready "script": "./p2-medium-86.js", "title": "Urban Regeneration 柏林公园改造", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", @@ -11275,7 +11275,7 @@ storageManager.ready "script": "./p2-high-91.js", "title": "Australia’s camouflaged creatures 澳洲伪装生物", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", @@ -11290,7 +11290,7 @@ storageManager.ready "script": "./p1-high-92.js", "title": "Dust and the American West 美国西部尘埃", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", @@ -11305,7 +11305,7 @@ storageManager.ready "script": "./p2-medium-93.js", "title": "Antarctic research 南极考察", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", "filename": "182. P2 - Antarctic research 南极考察.html", @@ -11335,7 +11335,7 @@ storageManager.ready "script": "./p3-low-95.js", "title": "The strange world of sight 奇异的视觉世界", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", @@ -11395,7 +11395,7 @@ storageManager.ready "script": "./p1-low-99.js", "title": "The history of the bar code 条形码的历史", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", "filename": "188. P1 - The history of the bar code 条形码的历史.html", @@ -11455,7 +11455,7 @@ storageManager.ready "script": "./p2-low-103.js", "title": "The economic effect of climate 气候对经济的影响", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", @@ -11470,7 +11470,7 @@ storageManager.ready "script": "./p2-low-104.js", "title": "1115纸笔Should we stop eating meat 是否应该吃素", "category": "P2", - "frequency": "高频", + "frequency": "low", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", @@ -11515,7 +11515,7 @@ storageManager.ready "script": "./p1-low-107.js", "title": "The life of Beatrix Potter 彼得兔作家", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", @@ -11545,7 +11545,7 @@ storageManager.ready "script": "./p1-low-109.js", "title": "The Origin of Paper 造纸术起源", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 2.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", "filename": "23. P1 - The Origin of Paper 造纸术起源.html", @@ -11605,7 +11605,7 @@ storageManager.ready "script": "./p1-low-113.js", "title": "Thomas Young The last man who knew everything 托马斯·杨", "category": "P1", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", @@ -11620,7 +11620,7 @@ storageManager.ready "script": "./p1-low-114.js", "title": "Triumph of the City 城市的胜利", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 1.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", "filename": "28. P1 - Triumph of the City 城市的胜利.html", @@ -11665,7 +11665,7 @@ storageManager.ready "script": "./p1-medium-117.js", "title": "What Lucy Taught Us 露西化石", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", @@ -11695,7 +11695,7 @@ storageManager.ready "script": "./p1-medium-119.js", "title": "Wood 新西兰木材产业", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 2, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", "filename": "32. P1 - Wood 新西兰木材产业【次】.html", @@ -11740,7 +11740,7 @@ storageManager.ready "script": "./p2-low-122.js", "title": "Biophilic Design 亲自然设计", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", "filename": "35. P2 - Biophilic Design 亲自然设计.html", @@ -11770,7 +11770,7 @@ storageManager.ready "script": "./p2-high-124.js", "title": "Corporate Social Responsibility 企业社会责任", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", @@ -11815,7 +11815,7 @@ storageManager.ready "script": "./p1-low-127.js", "title": "Ambergris 龙涎香", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", "filename": "4. P1 - Ambergris 龙涎香.html", @@ -11845,7 +11845,7 @@ storageManager.ready "script": "./p2-medium-129.js", "title": "Intelligent behaviour in birds 鸟类智慧行为", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", @@ -11860,7 +11860,7 @@ storageManager.ready "script": "./p2-high-130.js", "title": "Investment in shares versus investment in other assets 回报数据分析", "category": "P2", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", @@ -11935,7 +11935,7 @@ storageManager.ready "script": "./p2-low-135.js", "title": "Skyscraper Farming 摩天大楼种植", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", @@ -11965,7 +11965,7 @@ storageManager.ready "script": "./p2-high-137.js", "title": "Surviving city life 动物适应城市", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", @@ -11995,7 +11995,7 @@ storageManager.ready "script": "./p2-high-139.js", "title": "The conquest of malaria in Italy 意大利疟疾防治", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", @@ -12040,7 +12040,7 @@ storageManager.ready "script": "./p2-low-142.js", "title": "The fashion industry 时尚产业", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", "filename": "53. P2 - The fashion industry 时尚产业.html", @@ -12055,7 +12055,7 @@ storageManager.ready "script": "./p2-low-143.js", "title": "The impact of invasive species 入侵物种的影响", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", @@ -12085,7 +12085,7 @@ storageManager.ready "script": "./p2-high-145.js", "title": "The return of monkey life 猴群回归", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", @@ -12115,7 +12115,7 @@ storageManager.ready "script": "./p2-low-147.js", "title": "Who wrote Shakespeare's plays 莎士比亚", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", @@ -12190,7 +12190,7 @@ storageManager.ready "script": "./p3-medium-152.js", "title": "Charles Darwin and Evolutionary Psychology 进化心理学", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", @@ -12235,7 +12235,7 @@ storageManager.ready "script": "./p3-medium-155.js", "title": "Does class size matter_ 课堂规模", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", @@ -12265,7 +12265,7 @@ storageManager.ready "script": "./p3-high-157.js", "title": "Flower Power 鲜花的力量(花之力)", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3.5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", @@ -12340,7 +12340,7 @@ storageManager.ready "script": "./p3-medium-162.js", "title": "Jean Piaget (1896–1980) 让·皮亚杰", "category": "P3", - "frequency": "高频", + "frequency": "low", "difficultyScore": 5, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", @@ -12400,7 +12400,7 @@ storageManager.ready "script": "./p3-low-166.js", "title": "Life on Mars_ 火星地球化改造", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", @@ -12430,7 +12430,7 @@ storageManager.ready "script": "./p3-medium-168.js", "title": "Marketing and the information age 信息时代营销", "category": "P3", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", @@ -12445,7 +12445,7 @@ storageManager.ready "script": "./p3-medium-169.js", "title": "(无题目) Music Language We All Speak 音乐语言", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4.5, "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", @@ -12550,7 +12550,7 @@ storageManager.ready "script": "./p3-medium-176.js", "title": "The Analysis of Fear 猴子恐惧实验", "category": "P3", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", @@ -12640,7 +12640,7 @@ storageManager.ready "script": "./p1-medium-182.js", "title": "Listening to the Ocean 海洋探测", "category": "P1", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": 3, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", @@ -12655,7 +12655,7 @@ storageManager.ready "script": "./p3-medium-183.js", "title": "The hazards of multitasking 多任务处理", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", @@ -12700,7 +12700,7 @@ storageManager.ready "script": "./p3-low-186.js", "title": "The Robbers Cave Study (山洞)群体行为实验", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4, "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", @@ -12910,7 +12910,7 @@ storageManager.ready "script": "./p2-medium-217.js", "title": "A mechanical friend for children 孩子的机器人朋友", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "三月/3.P2 高频/", "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", @@ -12925,7 +12925,7 @@ storageManager.ready "script": "./p2-high-192.js", "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", "category": "P2", - "frequency": "高频", + "frequency": "low", "difficultyScore": 3.5, "path": "三月/4.P2 次高频/", "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", @@ -12940,7 +12940,7 @@ storageManager.ready "script": "./p2-medium-209.js", "title": "Decision Fatigue 决策疲劳", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "三月/4.P2 次高频/", "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", @@ -12985,7 +12985,7 @@ storageManager.ready "script": "./p2-medium-058.js", "title": "Who wrote Shakespeare's plays 莎士比亚", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "三月/4.P2 次高频/", "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", @@ -13045,7 +13045,7 @@ storageManager.ready "script": "./p3-high-218.js", "title": "The Causes of Linguistic Change 语音的演变", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4.5, "path": "三月/5.P3 高频/", "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", @@ -13075,7 +13075,7 @@ storageManager.ready "script": "./p3-low-999.js", "title": "Risk taking", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "三月/5.P3 高频/", "filename": "P3 - Risk taking.html", @@ -13090,7 +13090,7 @@ storageManager.ready "script": "./p3-medium-197.js", "title": "Australia’s Megafauna Controversy 巨兽灭绝", "category": "P3", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4.5, "path": "三月/6.P3 次高频/", "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", @@ -13120,7 +13120,7 @@ storageManager.ready "script": "./p3-low-078.js", "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", "category": "P3", - "frequency": "次高频", + "frequency": "low", "difficultyScore": 4.5, "path": "三月/6.P3 次高频/", "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", @@ -13135,7 +13135,7 @@ storageManager.ready "script": "./p1-high-227.js", "title": "The Whale Goes to Court 鲸鱼油", "category": "P1", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 3, "path": "ReadingPractice/PDF/", "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", @@ -13225,7 +13225,7 @@ storageManager.ready "script": "./p2-high-232.js", "title": "The origin and development of applause 掌声的历史", "category": "P2", - "frequency": "次高频", + "frequency": "高频", "difficultyScore": 4, "path": "ReadingPractice/PDF/", "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", @@ -13300,7 +13300,7 @@ storageManager.ready "script": "./p2-high-236.js", "title": "War of the Plants 植物的战争", "category": "P2", - "frequency": "高频", + "frequency": "次高频", "difficultyScore": null, "path": "", "filename": "", @@ -13330,7 +13330,7 @@ storageManager.ready "script": "./p2-high-239.js", "title": "Nanotechnology: the science of the very small 纳米科技", "category": "P2", - "frequency": "高频", + "frequency": "low", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", @@ -13345,7 +13345,7 @@ storageManager.ready "script": "./p2-low-240.js", "title": "Coins - the first form of money 硬币起源", "category": "P2", - "frequency": "low", + "frequency": "次高频", "difficultyScore": null, "path": "assets/generated/reading-exams/", "filename": "reading-practice-unified.html", @@ -13360,7 +13360,7 @@ storageManager.ready "script": "./p1-high-240.js", "title": "The Origins of Weather Forecasting 天气预报", "category": "P1", - "frequency": "high", + "frequency": "高频", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", @@ -13375,7 +13375,7 @@ storageManager.ready "script": "./p2-low-242.js", "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", "category": "P2", - "frequency": "low", + "frequency": "高频", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", @@ -13405,7 +13405,7 @@ storageManager.ready "script": "./p3-medium-241.js", "title": "Who looks after the children in today's Britain? 育儿分工", "category": "P3", - "frequency": "medium", + "frequency": "low", "difficultyScore": null, "path": "ReadingPractice/PDF/", "filename": "P3 - Who looks after the children in today's Britain.pdf", From accd0398990d18c7f07084784097c15000d51b7c Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Mon, 3 Aug 2026 22:57:09 +0800 Subject: [PATCH 07/18] Update IELTS reading exam sources and manifest --- assets/generated/reading-exams/manifest.js | 6991 +++++++++-------- assets/generated/reading-exams/p1-low-84.js | 2 +- .../generated/reading-exams/p1-medium-115.js | 16 +- .../generated/reading-exams/p2-medium-243.js | 126 + assets/generated/reading-exams/p3-high-173.js | 18 +- .../generated/reading-exams/p3-medium-244.js | 124 + js/bundles/core-foundation.bundle.js | 6991 +++++++++-------- 7 files changed, 7290 insertions(+), 6978 deletions(-) create mode 100644 assets/generated/reading-exams/p2-medium-243.js create mode 100644 assets/generated/reading-exams/p3-medium-244.js diff --git a/assets/generated/reading-exams/manifest.js b/assets/generated/reading-exams/manifest.js index 713207d8..61490cec 100644 --- a/assets/generated/reading-exams/manifest.js +++ b/assets/generated/reading-exams/manifest.js @@ -5,3486 +5,3517 @@ "listening": "ListeningPractice/" }; const manifest = { - "p1-high-01": { - "examId": "p1-high-01", - "dataKey": "p1-high-01", - "script": "./p1-high-01.js", - "title": "A Brief History of Tea 茶叶简史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/", - "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-02": { - "examId": "p1-low-02", - "dataKey": "p1-low-02", - "script": "./p1-low-02.js", - "title": "Maori Fish Hooks 毛利鱼钩", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/", - "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-03": { - "examId": "p3-high-03", - "dataKey": "p3-high-03", - "script": "./p3-high-03.js", - "title": "What makes a musical expert_ 音乐天赋", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", - "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-04": { - "examId": "p3-high-04", - "dataKey": "p3-high-04", - "script": "./p3-high-04.js", - "title": "Yawning 打呵欠", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", - "filename": "101. P3 - Yawning 打呵欠【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-05": { - "examId": "p1-high-05", - "dataKey": "p1-high-05", - "script": "./p1-high-05.js", - "title": "Katherine Mansfield 新西兰作家", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/", - "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-06": { - "examId": "p2-low-06", - "dataKey": "p2-low-06", - "script": "./p2-low-06.js", - "title": "Biomimicry 仿生学", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", - "filename": "103. P2 - Biomimicry 仿生学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-07": { - "examId": "p3-low-07", - "dataKey": "p3-low-07", - "script": "./p3-low-07.js", - "title": "Star Performers 明星员工", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", - "filename": "104. P3 - Star Performers 明星员工.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-08": { - "examId": "p2-low-08", - "dataKey": "p2-low-08", - "script": "./p2-low-08.js", - "title": "How the Petri dish supports scientific advances 培养皿", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/", - "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-09": { - "examId": "p2-high-09", - "dataKey": "p2-high-09", - "script": "./p2-high-09.js", - "title": "Early Approaches to Organisational Design 组织设计", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", - "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-10": { - "examId": "p2-medium-10", - "dataKey": "p2-medium-10", - "script": "./p2-medium-10.js", - "title": "A study of western celebrity 西方名人", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/", - "filename": "107. P2 - A study of western celebrity 西方名人【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-11": { - "examId": "p1-low-11", - "dataKey": "p1-low-11", - "script": "./p1-low-11.js", - "title": "Bovids 牛科动物", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/", - "filename": "108. P1 - Bovids 牛科动物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-12": { - "examId": "p3-low-12", - "dataKey": "p3-low-12", - "script": "./p3-low-12.js", - "title": "Humanities and the health professional 人文医学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/", - "filename": "109. P3 - Humanities and the health professional 人文医学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-13": { - "examId": "p1-low-13", - "dataKey": "p1-low-13", - "script": "./p1-low-13.js", - "title": "Report on a university drama project 大学戏剧项目报告", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", - "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-14": { - "examId": "p2-high-14", - "dataKey": "p2-high-14", - "script": "./p2-high-14.js", - "title": "Should space be explored by robots or by humans 人机太空探索", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", - "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-15": { - "examId": "p3-high-15", - "dataKey": "p3-high-15", - "script": "./p3-high-15.js", - "title": "Whale Culture 鲸鱼文化", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", - "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-16": { - "examId": "p2-high-16", - "dataKey": "p2-high-16", - "script": "./p2-high-16.js", - "title": "The Importance of Law 法律的意义", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/", - "filename": "112. P2 - The Importance of Law 法律的意义【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-17": { - "examId": "p2-high-17", - "dataKey": "p2-high-17", - "script": "./p2-high-17.js", - "title": "Herbal Medicines 新西兰草药", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/", - "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-18": { - "examId": "p3-medium-18", - "dataKey": "p3-medium-18", - "script": "./p3-medium-18.js", - "title": "Unlocking the mystery of dreams 梦的解析", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/", - "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-19": { - "examId": "p2-high-19", - "dataKey": "p2-high-19", - "script": "./p2-high-19.js", - "title": "Mind Music 脑海中的音乐(心灵音乐)", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", - "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-20": { - "examId": "p1-medium-20", - "dataKey": "p1-medium-20", - "script": "./p1-medium-20.js", - "title": "The Development of Plastics 塑料的发展史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/", - "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-21": { - "examId": "p2-high-21", - "dataKey": "p2-high-21", - "script": "./p2-high-21.js", - "title": "Stress Less 工作压力", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", - "filename": "117. P2 - Stress Less 工作压力【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-22": { - "examId": "p3-medium-22", - "dataKey": "p3-medium-22", - "script": "./p3-medium-22.js", - "title": "Neanderthal Technology 尼安德特人的生存技艺", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", - "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-23": { - "examId": "p2-high-23", - "dataKey": "p2-high-23", - "script": "./p2-high-23.js", - "title": "The Constant Evolution of the Humble Tomato 番茄的演化", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", - "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-24": { - "examId": "p1-high-24", - "dataKey": "p1-high-24", - "script": "./p1-high-24.js", - "title": "Rubber 橡胶", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/", - "filename": "12. P1 - Rubber 橡胶【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-25": { - "examId": "p2-high-25", - "dataKey": "p2-high-25", - "script": "./p2-high-25.js", - "title": "Will Eating Less Make You Live Longer 节食与长寿", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", - "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-27": { - "examId": "p1-high-27", - "dataKey": "p1-high-27", - "script": "./p1-high-27.js", - "title": "Footprints in the Mud 恐龙脚印", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", - "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-28": { - "examId": "p3-low-28", - "dataKey": "p3-low-28", - "script": "./p3-low-28.js", - "title": "Images and Places 风景与印记", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/", - "filename": "123. P3 - Images and Places 风景与印记.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-29": { - "examId": "p1-medium-29", - "dataKey": "p1-medium-29", - "script": "./p1-medium-29.js", - "title": "The extinction of the cave bear 洞熊的灭绝", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/", - "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-30": { - "examId": "p1-low-30", - "dataKey": "p1-low-30", - "script": "./p1-low-30.js", - "title": "Investing in the Future 投资未来", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", - "filename": "125. P1 - Investing in the Future 投资未来.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-31": { - "examId": "p1-high-31", - "dataKey": "p1-high-31", - "script": "./p1-high-31.js", - "title": "Dolls through the ages 玩偶的变迁史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/", - "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-32": { - "examId": "p3-high-32", - "dataKey": "p3-high-32", - "script": "./p3-high-32.js", - "title": "Science and Filmmaking 电影科学(CGI)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/", - "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-33": { - "examId": "p1-medium-33", - "dataKey": "p1-medium-33", - "script": "./p1-medium-33.js", - "title": "The Pyramid of Cestius 罗马金字塔", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", - "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-34": { - "examId": "p1-low-34", - "dataKey": "p1-low-34", - "script": "./p1-low-34.js", - "title": "The Slow Food Organization 慢食运动组织", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/", - "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-35": { - "examId": "p1-low-35", - "dataKey": "p1-low-35", - "script": "./p1-low-35.js", - "title": "Sweet Trouble 澳洲制糖产业", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/", - "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-36": { - "examId": "p3-low-36", - "dataKey": "p3-low-36", - "script": "./p3-low-36.js", - "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/", - "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-37": { - "examId": "p2-low-37", - "dataKey": "p2-low-37", - "script": "./p2-low-37.js", - "title": "Keeping the water away 洪水防控", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", - "filename": "131. P2 - Keeping the water away 洪水防控.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-38": { - "examId": "p3-low-38", - "dataKey": "p3-low-38", - "script": "./p3-low-38.js", - "title": "Research into the effects of different teaching styles 教学风格研究", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/", - "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-39": { - "examId": "p2-low-39", - "dataKey": "p2-low-39", - "script": "./p2-low-39.js", - "title": "How to be Happy 如何获得幸福", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/", - "filename": "133. P2 - How to be Happy 如何获得幸福.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-40": { - "examId": "p1-low-40", - "dataKey": "p1-low-40", - "script": "./p1-low-40.js", - "title": "Dyes and fabric dyeing 染料的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/", - "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-41": { - "examId": "p2-low-41", - "dataKey": "p2-low-41", - "script": "./p2-low-41.js", - "title": "The Myth of the Eight-hour Sleep 八小时睡眠", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/", - "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-42": { - "examId": "p3-low-42", - "dataKey": "p3-low-42", - "script": "./p3-low-42.js", - "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", - "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-43": { - "examId": "p3-low-43", - "dataKey": "p3-low-43", - "script": "./p3-low-43.js", - "title": "What is social history 社会史", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", - "filename": "137. P3 - What is social history 社会史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-44": { - "examId": "p3-low-44", - "dataKey": "p3-low-44", - "script": "./p3-low-44.js", - "title": "Conformity 从众心理", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/", - "filename": "138. P3 - Conformity 从众心理.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-45": { - "examId": "p1-low-45", - "dataKey": "p1-low-45", - "script": "./p1-low-45.js", - "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", - "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-46": { - "examId": "p1-low-46", - "dataKey": "p1-low-46", - "script": "./p1-low-46.js", - "title": "Sydney Opera House 悉尼歌剧院", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", - "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-47": { - "examId": "p1-low-47", - "dataKey": "p1-low-47", - "script": "./p1-low-47.js", - "title": "The Burgess Shale fossils 伯吉斯页岩", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/", - "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-48": { - "examId": "p1-low-48", - "dataKey": "p1-low-48", - "script": "./p1-low-48.js", - "title": "The history of the guitar 吉他的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", - "filename": "141. P1 - The history of the guitar 吉他的历史.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p2-low-49": { - "examId": "p2-low-49", - "dataKey": "p2-low-49", - "script": "./p2-low-49.js", - "title": "Born to Trade 交易的本能", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/", - "filename": "142. P2 - Born to Trade 交易的本能.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-50": { - "examId": "p2-low-50", - "dataKey": "p2-low-50", - "script": "./p2-low-50.js", - "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", - "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-51": { - "examId": "p2-low-51", - "dataKey": "p2-low-51", - "script": "./p2-low-51.js", - "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/", - "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-52": { - "examId": "p1-low-52", - "dataKey": "p1-low-52", - "script": "./p1-low-52.js", - "title": "Caral an ancient South American city 卡拉尔古城", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", - "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-53": { - "examId": "p1-low-53", - "dataKey": "p1-low-53", - "script": "./p1-low-53.js", - "title": "The Early History of Olive Oil 橄榄油的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/", - "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-54": { - "examId": "p3-low-54", - "dataKey": "p3-low-54", - "script": "./p3-low-54.js", - "title": "Movement Underwater 水下运动", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", - "filename": "147. P3 - Movement Underwater 水下运动.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-55": { - "examId": "p3-low-55", - "dataKey": "p3-low-55", - "script": "./p3-low-55.js", - "title": "Improving Patient Safety 药品包装设计", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/", - "filename": "148. P3 - Improving Patient Safety 药品包装设计.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-56": { - "examId": "p3-low-56", - "dataKey": "p3-low-56", - "script": "./p3-low-56.js", - "title": "Learning to be bilingual 双语学习", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/", - "filename": "149. P3 - Learning to be bilingual 双语学习.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-57": { - "examId": "p1-medium-57", - "dataKey": "p1-medium-57", - "script": "./p1-medium-57.js", - "title": "The Blockbuster Phenomenon 博物馆爆款现象", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/", - "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-58": { - "examId": "p2-medium-58", - "dataKey": "p2-medium-58", - "script": "./p2-medium-58.js", - "title": "Insect Decision-Making 昆虫决策", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/", - "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-59": { - "examId": "p3-low-59", - "dataKey": "p3-low-59", - "script": "./p3-low-59.js", - "title": "Inside the mind of a fan 观赛心境", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/", - "filename": "151. P3 - Inside the mind of a fan 观赛心境.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-60": { - "examId": "p1-medium-60", - "dataKey": "p1-medium-60", - "script": "./p1-medium-60.js", - "title": "Sorry—who are you 脸盲症", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/", - "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-61": { - "examId": "p1-low-61", - "dataKey": "p1-low-61", - "script": "./p1-low-61.js", - "title": "Carnivorous plants 食虫植物", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/", - "filename": "153. P1 - Carnivorous plants 食虫植物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-62": { - "examId": "p2-low-62", - "dataKey": "p2-low-62", - "script": "./p2-low-62.js", - "title": "The purpose of facial expressions 面部表情", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/", - "filename": "154. P2 - The purpose of facial expressions 面部表情.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-63": { - "examId": "p1-medium-63", - "dataKey": "p1-medium-63", - "script": "./p1-medium-63.js", - "title": "A Brief History of Humans and Food 人类食物的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/", - "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-64": { - "examId": "p2-low-64", - "dataKey": "p2-low-64", - "script": "./p2-low-64.js", - "title": "New filter promises clean water for millions 新型泥土净水器", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/", - "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-65": { - "examId": "p2-low-65", - "dataKey": "p2-low-65", - "script": "./p2-low-65.js", - "title": "Boring Buildings 无聊建筑", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/", - "filename": "157. P2 - Boring Buildings 无聊建筑.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-66": { - "examId": "p3-medium-66", - "dataKey": "p3-medium-66", - "script": "./p3-medium-66.js", - "title": "Mercator - The Map Maker 地理制图师", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/", - "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-67": { - "examId": "p1-low-67", - "dataKey": "p1-low-67", - "script": "./p1-low-67.js", - "title": "Scented Plants 植物的味道", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/", - "filename": "159. P1 - Scented Plants 植物的味道.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-68": { - "examId": "p1-low-68", - "dataKey": "p1-low-68", - "script": "./p1-low-68.js", - "title": "The Clipper Races 帆船竞速", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", - "filename": "16. P1 - The Clipper Races 帆船竞速.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-69": { - "examId": "p1-low-69", - "dataKey": "p1-low-69", - "script": "./p1-low-69.js", - "title": "An important language development 楔形文字", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/", - "filename": "160. P1 - An important language development 楔形文字.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-70": { - "examId": "p1-low-70", - "dataKey": "p1-low-70", - "script": "./p1-low-70.js", - "title": "Fluorescence Deep sea discovery深海发光生物研究", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", - "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-71": { - "examId": "p3-low-71", - "dataKey": "p3-low-71", - "script": "./p3-low-71.js", - "title": "Sea Change for Salinity 土地盐碱化", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/", - "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-72": { - "examId": "p1-low-72", - "dataKey": "p1-low-72", - "script": "./p1-low-72.js", - "title": "How to find your way out of a food desert 城市食物荒漠", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/", - "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-73": { - "examId": "p2-low-73", - "dataKey": "p2-low-73", - "script": "./p2-low-73.js", - "title": "The Power of Smell 嗅觉的力量", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/", - "filename": "164. P2 - The Power of Smell 嗅觉的力量.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-74": { - "examId": "p3-low-74", - "dataKey": "p3-low-74", - "script": "./p3-low-74.js", - "title": "The Placebo Effect5 安慰剂效应", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/", - "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-75": { - "examId": "p2-low-75", - "dataKey": "p2-low-75", - "script": "./p2-low-75.js", - "title": "Lean Production Innovation 精益生产", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", - "filename": "166. P2 - Lean Production Innovation 精益生产.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-76": { - "examId": "p3-low-76", - "dataKey": "p3-low-76", - "script": "./p3-low-76.js", - "title": "Sign, Baby, Sign! 美国手语", - "category": "P3", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/", - "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-77": { - "examId": "p2-low-77", - "dataKey": "p2-low-77", - "script": "./p2-low-77.js", - "title": "Mammoth Kill 猛犸象的灭绝", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", - "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-78": { - "examId": "p3-low-78", - "dataKey": "p3-low-78", - "script": "./p3-low-78.js", - "title": "The Costs of Brand Loyalty 品牌忠诚的代价", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/", - "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-79": { - "examId": "p1-high-79", - "dataKey": "p1-high-79", - "script": "./p1-high-79.js", - "title": "The Development of The Silk Industry 丝绸产业发展", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/", - "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-80": { - "examId": "p1-low-80", - "dataKey": "p1-low-80", - "script": "./p1-low-80.js", - "title": "The unsung sense 被低估的嗅觉", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", - "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-81": { - "examId": "p1-low-81", - "dataKey": "p1-low-81", - "script": "./p1-low-81.js", - "title": "Salt 盐的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/", - "filename": "171. P1 - Salt 盐的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-82": { - "examId": "p1-high-82", - "dataKey": "p1-high-82", - "script": "./p1-high-82.js", - "title": "Think Small 微观科学", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", - "filename": "172. P1 - Think Small 微观科学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-83": { - "examId": "p3-low-83", - "dataKey": "p3-low-83", - "script": "./p3-low-83.js", - "title": "1018纸笔 Looking for inspiration 寻找灵感", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/", - "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-84": { - "examId": "p1-low-84", - "dataKey": "p1-low-84", - "script": "./p1-low-84.js", - "title": "Why good ideas fail TF公司", - "category": "P1", - "frequency": "low", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", - "filename": "174. P1 - Why good ideas fail TF公司.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-85": { - "examId": "p3-low-85", - "dataKey": "p3-low-85", - "script": "./p3-low-85.js", - "title": "Music soothes and awes 音乐疗愈", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/", - "filename": "175. P3 - Music soothes and awes 音乐疗愈.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-86": { - "examId": "p2-medium-86", - "dataKey": "p2-medium-86", - "script": "./p2-medium-86.js", - "title": "Urban Regeneration 柏林公园改造", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", - "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-87": { - "examId": "p2-low-87", - "dataKey": "p2-low-87", - "script": "./p2-low-87.js", - "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/", - "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-88": { - "examId": "p3-low-88", - "dataKey": "p3-low-88", - "script": "./p3-low-88.js", - "title": "1025纸笔Translating a key to international understanding 翻译的艺术", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/", - "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-89": { - "examId": "p3-high-89", - "dataKey": "p3-high-89", - "script": "./p3-high-89.js", - "title": "Looking at daily life in ancient Rome 古罗马的日常", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/", - "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-90": { - "examId": "p1-high-90", - "dataKey": "p1-high-90", - "script": "./p1-high-90.js", - "title": "The History of Tea 茶叶的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/", - "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-91": { - "examId": "p2-high-91", - "dataKey": "p2-high-91", - "script": "./p2-high-91.js", - "title": "Australia’s camouflaged creatures 澳洲伪装生物", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", - "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-92": { - "examId": "p1-high-92", - "dataKey": "p1-high-92", - "script": "./p1-high-92.js", - "title": "Dust and the American West 美国西部尘埃", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", - "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-93": { - "examId": "p2-medium-93", - "dataKey": "p2-medium-93", - "script": "./p2-medium-93.js", - "title": "Antarctic research 南极考察", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", - "filename": "182. P2 - Antarctic research 南极考察.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-94": { - "examId": "p2-low-94", - "dataKey": "p2-low-94", - "script": "./p2-low-94.js", - "title": "The importance of being playful 玩耍的重要性", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/", - "filename": "183. P2 - The importance of being playful 玩耍的重要性.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-95": { - "examId": "p3-low-95", - "dataKey": "p3-low-95", - "script": "./p3-low-95.js", - "title": "The strange world of sight 奇异的视觉世界", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", - "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-96": { - "examId": "p2-low-96", - "dataKey": "p2-low-96", - "script": "./p2-low-96.js", - "title": "[Pretest] Why Do We Need Sleep 睡眠的目的", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/", - "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-97": { - "examId": "p3-low-97", - "dataKey": "p3-low-97", - "script": "./p3-low-97.js", - "title": "Saving languages 拯救濒危语言", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/", - "filename": "186. P3 - Saving languages 拯救濒危语言.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-98": { - "examId": "p3-low-98", - "dataKey": "p3-low-98", - "script": "./p3-low-98.js", - "title": "Petrol power an eco-revolution 交通的革命", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/", - "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-99": { - "examId": "p1-low-99", - "dataKey": "p1-low-99", - "script": "./p1-low-99.js", - "title": "The history of the bar code 条形码的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", - "filename": "188. P1 - The history of the bar code 条形码的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-100": { - "examId": "p3-low-100", - "dataKey": "p3-low-100", - "script": "./p3-low-100.js", - "title": "Mirror 镜子研究", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/", - "filename": "189. P3 - Mirror 镜子研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-101": { - "examId": "p1-high-101", - "dataKey": "p1-high-101", - "script": "./p1-high-101.js", - "title": "The Impact of the Potato 土豆的影响", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/", - "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-102": { - "examId": "p2-low-102", - "dataKey": "p2-low-102", - "script": "./p2-low-102.js", - "title": "The power of music 音乐的力量", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/", - "filename": "190. P2 - The power of music 音乐的力量.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-103": { - "examId": "p2-low-103", - "dataKey": "p2-low-103", - "script": "./p2-low-103.js", - "title": "The economic effect of climate 气候对经济的影响", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", - "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-104": { - "examId": "p2-low-104", - "dataKey": "p2-low-104", - "script": "./p2-low-104.js", - "title": "1115纸笔Should we stop eating meat 是否应该吃素", - "category": "P2", - "frequency": "low", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", - "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-105": { - "examId": "p1-high-105", - "dataKey": "p1-high-105", - "script": "./p1-high-105.js", - "title": "A survivor’s story 新西兰猫头鹰", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/", - "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-106": { - "examId": "p1-low-106", - "dataKey": "p1-low-106", - "script": "./p1-low-106.js", - "title": "The Importance of Business Cards 名片的重要性", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/", - "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-107": { - "examId": "p1-low-107", - "dataKey": "p1-low-107", - "script": "./p1-low-107.js", - "title": "The life of Beatrix Potter 彼得兔作家", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", - "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-108": { - "examId": "p1-low-108", - "dataKey": "p1-low-108", - "script": "./p1-low-108.js", - "title": "The nature of Yawning 打哈欠的本质", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/", - "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-109": { - "examId": "p1-low-109", - "dataKey": "p1-low-109", - "script": "./p1-low-109.js", - "title": "The Origin of Paper 造纸术起源", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", - "filename": "23. P1 - The Origin of Paper 造纸术起源.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-110": { - "examId": "p1-high-110", - "dataKey": "p1-high-110", - "script": "./p1-high-110.js", - "title": "The Pearls 珍珠", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/", - "filename": "24. P1 - The Pearls 珍珠【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-111": { - "examId": "p1-low-111", - "dataKey": "p1-low-111", - "script": "./p1-low-111.js", - "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/", - "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-112": { - "examId": "p1-low-112", - "dataKey": "p1-low-112", - "script": "./p1-low-112.js", - "title": "The Tuatara of New Zealand 新西兰蜥蜴", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/", - "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-113": { - "examId": "p1-low-113", - "dataKey": "p1-low-113", - "script": "./p1-low-113.js", - "title": "Thomas Young The last man who knew everything 托马斯·杨", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", - "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-114": { - "examId": "p1-low-114", - "dataKey": "p1-low-114", - "script": "./p1-low-114.js", - "title": "Triumph of the City 城市的胜利", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 1.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", - "filename": "28. P1 - Triumph of the City 城市的胜利.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-115": { - "examId": "p1-medium-115", - "dataKey": "p1-medium-115", - "script": "./p1-medium-115.js", - "title": "Tunnelling under the Thames 泰晤士河隧道", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", - "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-116": { - "examId": "p1-low-116", - "dataKey": "p1-low-116", - "script": "./p1-low-116.js", - "title": "Advertising Needs Attention 广告的吸引力", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/", - "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-117": { - "examId": "p1-medium-117", - "dataKey": "p1-medium-117", - "script": "./p1-medium-117.js", - "title": "What Lucy Taught Us 露西化石", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", - "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-118": { - "examId": "p1-high-118", - "dataKey": "p1-high-118", - "script": "./p1-high-118.js", - "title": "William Gilbert and Magnetism 电磁学之父", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/", - "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-119": { - "examId": "p1-medium-119", - "dataKey": "p1-medium-119", - "script": "./p1-medium-119.js", - "title": "Wood 新西兰木材产业", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", - "filename": "32. P1 - Wood 新西兰木材产业【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-120": { - "examId": "p2-high-120", - "dataKey": "p2-high-120", - "script": "./p2-high-120.js", - "title": "A new look for Talbot Park 奥克兰社区改造", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/", - "filename": "ai_studio_code (9).html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-121": { - "examId": "p2-medium-121", - "dataKey": "p2-medium-121", - "script": "./p2-medium-121.js", - "title": "A unique golden textile 蜘蛛丝", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/", - "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-122": { - "examId": "p2-low-122", - "dataKey": "p2-low-122", - "script": "./p2-low-122.js", - "title": "Biophilic Design 亲自然设计", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", - "filename": "35. P2 - Biophilic Design 亲自然设计.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-123": { - "examId": "p2-high-123", - "dataKey": "p2-high-123", - "script": "./p2-high-123.js", - "title": "Bird Migration 鸟类迁徙", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/", - "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-124": { - "examId": "p2-high-124", - "dataKey": "p2-high-124", - "script": "./p2-high-124.js", - "title": "Corporate Social Responsibility 企业社会责任", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", - "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-125": { - "examId": "p2-low-125", - "dataKey": "p2-low-125", - "script": "./p2-low-125.js", - "title": "Egypt’s ancient boat-builders 古埃及造船", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/", - "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-126": { - "examId": "p2-medium-126", - "dataKey": "p2-medium-126", - "script": "./p2-medium-126.js", - "title": "How are deserts formed 沙漠成因", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/", - "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-127": { - "examId": "p1-low-127", - "dataKey": "p1-low-127", - "script": "./p1-low-127.js", - "title": "Ambergris 龙涎香", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", - "filename": "4. P1 - Ambergris 龙涎香.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-128": { - "examId": "p2-high-128", - "dataKey": "p2-high-128", - "script": "./p2-high-128.js", - "title": "How Well Do We Concentrate_ 多任务处理", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/", - "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-129": { - "examId": "p2-medium-129", - "dataKey": "p2-medium-129", - "script": "./p2-medium-129.js", - "title": "Intelligent behaviour in birds 鸟类智慧行为", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", - "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-130": { - "examId": "p2-high-130", - "dataKey": "p2-high-130", - "script": "./p2-high-130.js", - "title": "Investment in shares versus investment in other assets 回报数据分析", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", - "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-131": { - "examId": "p2-high-131", - "dataKey": "p2-high-131", - "script": "./p2-high-131.js", - "title": "Learning from the Romans 罗马混凝土", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/", - "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-132": { - "examId": "p2-low-132", - "dataKey": "p2-low-132", - "script": "./p2-low-132.js", - "title": "Orientation of Birds 鸟类的定位能力", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/", - "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-133": { - "examId": "p2-high-133", - "dataKey": "p2-high-133", - "script": "./p2-high-133.js", - "title": "Playing soccer 街头足球", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/", - "filename": "45. P2 - Playing soccer 街头足球【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-134": { - "examId": "p2-high-134", - "dataKey": "p2-high-134", - "script": "./p2-high-134.js", - "title": "Roller coaster 过山车", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/", - "filename": "46. P2 - Roller coaster 过山车【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-135": { - "examId": "p2-low-135", - "dataKey": "p2-low-135", - "script": "./p2-low-135.js", - "title": "Skyscraper Farming 摩天大楼种植", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", - "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-136": { - "examId": "p2-high-136", - "dataKey": "p2-high-136", - "script": "./p2-high-136.js", - "title": "Solving the problem of waste disposal 垃圾处理", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/", - "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-137": { - "examId": "p2-high-137", - "dataKey": "p2-high-137", - "script": "./p2-high-137.js", - "title": "Surviving city life 动物适应城市", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", - "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-138": { - "examId": "p1-low-138", - "dataKey": "p1-low-138", - "script": "./p1-low-138.js", - "title": "Australian artist Margaret Preston 澳大利亚艺术家", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/", - "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-139": { - "examId": "p2-high-139", - "dataKey": "p2-high-139", - "script": "./p2-high-139.js", - "title": "The conquest of malaria in Italy 意大利疟疾防治", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", - "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-140": { - "examId": "p2-low-140", - "dataKey": "p2-low-140", - "script": "./p2-low-140.js", - "title": "The dingo debate 澳洲野犬", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/", - "filename": "51. P2 - The dingo debate 澳洲野犬.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-141": { - "examId": "p2-high-141", - "dataKey": "p2-high-141", - "script": "./p2-high-141.js", - "title": "The fascinating world of attine ants 切叶蚁", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/", - "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-142": { - "examId": "p2-low-142", - "dataKey": "p2-low-142", - "script": "./p2-low-142.js", - "title": "The fashion industry 时尚产业", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", - "filename": "53. P2 - The fashion industry 时尚产业.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-143": { - "examId": "p2-low-143", - "dataKey": "p2-low-143", - "script": "./p2-low-143.js", - "title": "The impact of invasive species 入侵物种的影响", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", - "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-144": { - "examId": "p2-medium-144", - "dataKey": "p2-medium-144", - "script": "./p2-medium-144.js", - "title": "The plan to bring an asteroid to Earth 捕获小行星", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/", - "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-145": { - "examId": "p2-high-145", - "dataKey": "p2-high-145", - "script": "./p2-high-145.js", - "title": "The return of monkey life 猴群回归", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", - "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-146": { - "examId": "p2-medium-146", - "dataKey": "p2-medium-146", - "script": "./p2-medium-146.js", - "title": "The Tasmanian Tiger 袋狼", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/", - "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-147": { - "examId": "p2-low-147", - "dataKey": "p2-low-147", - "script": "./p2-low-147.js", - "title": "Who wrote Shakespeare's plays 莎士比亚", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", - "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-148": { - "examId": "p2-low-148", - "dataKey": "p2-low-148", - "script": "./p2-low-148.js", - "title": "Why do we need the arts_ 艺术的意义", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/", - "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-149": { - "examId": "p1-low-149", - "dataKey": "p1-low-149", - "script": "./p1-low-149.js", - "title": "Categorizing societies 社会分类", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/", - "filename": "6. P1 - Categorizing societies 社会分类html.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-150": { - "examId": "p3-high-150", - "dataKey": "p3-high-150", - "script": "./p3-high-150.js", - "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/", - "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-151": { - "examId": "p3-low-151", - "dataKey": "p3-low-151", - "script": "./p3-low-151.js", - "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/", - "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-152": { - "examId": "p3-medium-152", - "dataKey": "p3-medium-152", - "script": "./p3-medium-152.js", - "title": "Charles Darwin and Evolutionary Psychology 进化心理学", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", - "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-153": { - "examId": "p3-low-153", - "dataKey": "p3-low-153", - "script": "./p3-low-153.js", - "title": "Crossing the Threshold 奥克兰美术馆", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/", - "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-154": { - "examId": "p3-medium-154", - "dataKey": "p3-medium-154", - "script": "./p3-medium-154.js", - "title": "Decisions, Decisions 决策之间", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/", - "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-155": { - "examId": "p3-medium-155", - "dataKey": "p3-medium-155", - "script": "./p3-medium-155.js", - "title": "Does class size matter_ 课堂规模", - "category": "P3", - "frequency": "low", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", - "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-156": { - "examId": "p3-high-156", - "dataKey": "p3-high-156", - "script": "./p3-high-156.js", - "title": "Elephant Communication 大象交流", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/", - "filename": "66. P3 - Elephant Communication 大象交流【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-157": { - "examId": "p3-high-157", - "dataKey": "p3-high-157", - "script": "./p3-high-157.js", - "title": "Flower Power 鲜花的力量(花之力)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", - "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf", - "sourceKind": "generated-reading" - }, - "p3-low-158": { - "examId": "p3-low-158", - "dataKey": "p3-low-158", - "script": "./p3-low-158.js", - "title": "Game theory 博弈论", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/", - "filename": "68. P3 - Game theory 博弈论.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-159": { - "examId": "p3-high-159", - "dataKey": "p3-high-159", - "script": "./p3-high-159.js", - "title": "Grimm’s Fairy Tales 格林童话", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/", - "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-160": { - "examId": "p1-low-160", - "dataKey": "p1-low-160", - "script": "./p1-low-160.js", - "title": "Chili peppers 辣椒的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/", - "filename": "7. P1 - Chili peppers 辣椒的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-161": { - "examId": "p3-high-161", - "dataKey": "p3-high-161", - "script": "./p3-high-161.js", - "title": "Insect-inspired robots 昆虫机器人", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/", - "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-162": { - "examId": "p3-medium-162", - "dataKey": "p3-medium-162", - "script": "./p3-medium-162.js", - "title": "Jean Piaget (1896–1980) 让·皮亚杰", - "category": "P3", - "frequency": "low", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", - "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-163": { - "examId": "p3-low-163", - "dataKey": "p3-low-163", - "script": "./p3-low-163.js", - "title": "Keeping the Fun in Funfairs 游乐场设计科学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/", - "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-164": { - "examId": "p3-high-164", - "dataKey": "p3-high-164", - "script": "./p3-high-164.js", - "title": "Language Strategy in Multinational Companies 跨国公司语言策略", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/", - "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-165": { - "examId": "p3-low-165", - "dataKey": "p3-low-165", - "script": "./p3-low-165.js", - "title": "Let’s teach them how to teach 教他们如何教学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/", - "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-166": { - "examId": "p3-low-166", - "dataKey": "p3-low-166", - "script": "./p3-low-166.js", - "title": "Life on Mars_ 火星地球化改造", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", - "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-167": { - "examId": "p3-high-167", - "dataKey": "p3-high-167", - "script": "./p3-high-167.js", - "title": "Living dunes 流动沙丘", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/", - "filename": "76. P3 - Living dunes 流动沙丘【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-168": { - "examId": "p3-medium-168", - "dataKey": "p3-medium-168", - "script": "./p3-medium-168.js", - "title": "Marketing and the information age 信息时代营销", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", - "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-169": { - "examId": "p3-medium-169", - "dataKey": "p3-medium-169", - "script": "./p3-medium-169.js", - "title": "(无题目) Music Language We All Speak 音乐语言", - "category": "P3", - "frequency": "low", - "difficultyScore": 4.5, - "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", - "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-170": { - "examId": "p3-high-170", - "dataKey": "p3-high-170", - "script": "./p3-high-170.js", - "title": "Pacific Navigation and Voyaging 太平洋航海", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/", - "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-171": { - "examId": "p1-high-171", - "dataKey": "p1-high-171", - "script": "./p1-high-171.js", - "title": "Fishbourne Roman Palace 罗马宫殿", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/", - "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-172": { - "examId": "p3-low-172", - "dataKey": "p3-low-172", - "script": "./p3-low-172.js", - "title": "Rebranding art museums 博物馆品牌重塑", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/", - "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-173": { - "examId": "p3-high-173", - "dataKey": "p3-high-173", - "script": "./p3-high-173.js", - "title": "Robert Louis Stevenson 苏格兰作家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", - "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-174": { - "examId": "p3-high-174", - "dataKey": "p3-high-174", - "script": "./p3-high-174.js", - "title": "Some views on the use of headphones 耳机使用", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/", - "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-175": { - "examId": "p3-low-175", - "dataKey": "p3-low-175", - "script": "./p3-low-175.js", - "title": "Termite Mounds 白蚁丘", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/", - "filename": "83. P3 - Termite Mounds 白蚁丘.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-176": { - "examId": "p3-medium-176", - "dataKey": "p3-medium-176", - "script": "./p3-medium-176.js", - "title": "The Analysis of Fear 猴子恐惧实验", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", - "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-177": { - "examId": "p3-medium-177", - "dataKey": "p3-medium-177", - "script": "./p3-medium-177.js", - "title": "The Art of Deception 欺骗的艺术", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/", - "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-178": { - "examId": "p3-high-178", - "dataKey": "p3-high-178", - "script": "./p3-high-178.js", - "title": "The benefits of learning an instrument 学乐器的好处", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/", - "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-179": { - "examId": "p3-medium-179", - "dataKey": "p3-medium-179", - "script": "./p3-medium-179.js", - "title": "The Exploration of Mars 火星探索", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/", - "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-180": { - "examId": "p3-high-180", - "dataKey": "p3-high-180", - "script": "./p3-high-180.js", - "title": "The fluoridation controversy 氟化水争议", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/", - "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-181": { - "examId": "p3-high-181", - "dataKey": "p3-high-181", - "script": "./p3-high-181.js", - "title": "The Fruit Book 果实之书", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/", - "filename": "89. P3 - The Fruit Book 果实之书【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-182": { - "examId": "p1-medium-182", - "dataKey": "p1-medium-182", - "script": "./p1-medium-182.js", - "title": "Listening to the Ocean 海洋探测", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", - "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-183": { - "examId": "p3-medium-183", - "dataKey": "p3-medium-183", - "script": "./p3-medium-183.js", - "title": "The hazards of multitasking 多任务处理", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", - "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-184": { - "examId": "p3-high-184", - "dataKey": "p3-high-184", - "script": "./p3-high-184.js", - "title": "The New Zealand writer Margaret Mahy 新西兰女作家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/", - "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-185": { - "examId": "p3-medium-185", - "dataKey": "p3-medium-185", - "script": "./p3-medium-185.js", - "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/", - "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-186": { - "examId": "p3-low-186", - "dataKey": "p3-low-186", - "script": "./p3-low-186.js", - "title": "The Robbers Cave Study (山洞)群体行为实验", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", - "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-187": { - "examId": "p3-low-187", - "dataKey": "p3-low-187", - "script": "./p3-low-187.js", - "title": "The science of sleep 睡眠的科学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/", - "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-188": { - "examId": "p3-medium-188", - "dataKey": "p3-medium-188", - "script": "./p3-medium-188.js", - "title": "The Significant Role of Mother Tongue in Education 母语教育", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/", - "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-189": { - "examId": "p3-high-189", - "dataKey": "p3-high-189", - "script": "./p3-high-189.js", - "title": "The tuatara – past and future 新西兰蜥蜴", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/", - "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-190": { - "examId": "p3-low-190", - "dataKey": "p3-low-190", - "script": "./p3-low-190.js", - "title": "The value of literary prizes 文学奖项的价值", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/", - "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-191": { - "examId": "p3-medium-191", - "dataKey": "p3-medium-191", - "script": "./p3-medium-191.js", - "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/", - "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-192": { - "examId": "p3-high-192", - "dataKey": "p3-high-192", - "script": "./p3-high-192.js", - "title": "Voynich Manuscript 伏尼契手稿", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/", - "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-200": { - "examId": "p1-high-200", - "dataKey": "p1-high-200", - "script": "./p1-high-200.js", - "title": "Australia’s Airborne Dentists 澳洲飞行牙医", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "三月/1.P1 高频/", - "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-211": { - "examId": "p1-high-211", - "dataKey": "p1-high-211", - "script": "./p1-high-211.js", - "title": "Ahead of its time 新西兰头骨", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "三月/1.P1 高频/", - "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-216": { - "examId": "p1-high-216", - "dataKey": "p1-high-216", - "script": "./p1-high-216.js", - "title": "Australia’s cane toad problem 澳洲蟾蜍", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "三月/1.P1 高频/", - "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-194": { - "examId": "p1-high-194", - "dataKey": "p1-high-194", - "script": "./p1-high-194.js", - "title": "The history of the British wool industry 英国羊毛产业的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "三月/2.P1 次高频/", - "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-222": { - "examId": "p2-low-222", - "dataKey": "p2-low-222", - "script": "./p2-low-222.js", - "title": "Ideal Homes 理想居所", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/", - "filename": "222. P2 - Ideal Homes 理想居所.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-223": { - "examId": "p1-low-223", - "dataKey": "p1-low-223", - "script": "./p1-low-223.js", - "title": "Effect and Cause 湖泊海啸研究", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "三月/", - "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-201": { - "examId": "p2-high-201", - "dataKey": "p2-high-201", - "script": "./p2-high-201.js", - "title": "Multi-tasking and the brain 大脑与多任务处理", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "三月/3.P2 高频/", - "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-217": { - "examId": "p2-medium-217", - "dataKey": "p2-medium-217", - "script": "./p2-medium-217.js", - "title": "A mechanical friend for children 孩子的机器人朋友", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "三月/3.P2 高频/", - "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-192": { - "examId": "p2-high-192", - "dataKey": "p2-high-192", - "script": "./p2-high-192.js", - "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", - "category": "P2", - "frequency": "low", - "difficultyScore": 3.5, - "path": "三月/4.P2 次高频/", - "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-209": { - "examId": "p2-medium-209", - "dataKey": "p2-medium-209", - "script": "./p2-medium-209.js", - "title": "Decision Fatigue 决策疲劳", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-213": { - "examId": "p2-medium-213", - "dataKey": "p2-medium-213", - "script": "./p2-medium-213.js", - "title": "Growing more for less 卫星农业", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "三月/4.P2 次高频/", - "filename": "213. P2 - Growing more for less 卫星农业【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-051": { - "examId": "p2-low-051", - "dataKey": "p2-low-051", - "script": "./p2-low-051.js", - "title": "The dingo debate 澳洲野犬_澳洲野狗", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-058": { - "examId": "p2-medium-058", - "dataKey": "p2-medium-058", - "script": "./p2-medium-058.js", - "title": "Who wrote Shakespeare's plays 莎士比亚", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-204": { - "examId": "p3-high-204", - "dataKey": "p3-high-204", - "script": "./p3-high-204.js", - "title": "When people are ‘deaf’ to music 失乐症", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-206": { - "examId": "p3-high-206", - "dataKey": "p3-high-206", - "script": "./p3-high-206.js", - "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-212": { - "examId": "p3-high-212", - "dataKey": "p3-high-212", - "script": "./p3-high-212.js", - "title": "Children’s literature studies today 儿童文学", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-218": { - "examId": "p3-high-218", - "dataKey": "p3-high-218", - "script": "./p3-high-218.js", - "title": "The Causes of Linguistic Change 语音的演变", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-219": { - "examId": "p3-low-219", - "dataKey": "p3-low-219", - "script": "./p3-low-219.js", - "title": "The origin of language 语言的起源", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "219. P3 - The origin of language 语言的起源.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-999": { - "examId": "p3-low-999", - "dataKey": "p3-low-999", - "script": "./p3-low-999.js", - "title": "Risk taking", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "P3 - Risk taking.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p3-medium-197": { - "examId": "p3-medium-197", - "dataKey": "p3-medium-197", - "script": "./p3-medium-197.js", - "title": "Australia’s Megafauna Controversy 巨兽灭绝", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/6.P3 次高频/", - "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-198": { - "examId": "p3-low-198", - "dataKey": "p3-low-198", - "script": "./p3-low-198.js", - "title": "Child’s Play in Medieval England 中世纪的游戏", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "三月/6.P3 次高频/", - "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-078": { - "examId": "p3-low-078", - "dataKey": "p3-low-078", - "script": "./p3-low-078.js", - "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", - "category": "P3", - "frequency": "low", - "difficultyScore": 4.5, - "path": "三月/6.P3 次高频/", - "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-227": { - "examId": "p1-high-227", - "dataKey": "p1-high-227", - "script": "./p1-high-227.js", - "title": "The Whale Goes to Court 鲸鱼油", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "ReadingPractice/PDF/", - "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-225": { - "examId": "p2-high-225", - "dataKey": "p2-high-225", - "script": "./p2-high-225.js", - "title": "The problem of graffiti 涂鸦之困", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "ReadingPractice/PDF/", - "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-228": { - "examId": "p3-high-228", - "dataKey": "p3-high-228", - "script": "./p3-high-228.js", - "title": "On art and artists 艺术与艺术家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "ReadingPractice/PDF/", - "filename": "228. P3 - On art and artists 艺术与艺术家.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-229": { - "examId": "p1-high-229", - "dataKey": "p1-high-229", - "script": "./p1-high-229.js", - "title": "New Understanding of Giraffes in the Wild 野生长颈鹿", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "ReadingPractice/PDF/", - "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-230": { - "examId": "p1-high-230", - "dataKey": "p1-high-230", - "script": "./p1-high-230.js", - "title": "The History of the Pencil 铅笔的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1.5, - "path": "ReadingPractice/PDF/", - "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-231": { - "examId": "p1-high-231", - "dataKey": "p1-high-231", - "script": "./p1-high-231.js", - "title": "The History of the Pencil 铅笔的历史(流程图版)", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "ReadingPractice/PDF/", - "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", - "sourceKind": "generated-reading" - }, - "p2-high-232": { - "examId": "p2-high-232", - "dataKey": "p2-high-232", - "script": "./p2-high-232.js", - "title": "The origin and development of applause 掌声的历史", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "ReadingPractice/PDF/", - "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-233": { - "examId": "p2-high-233", - "dataKey": "p2-high-233", - "script": "./p2-high-233.js", - "title": "Why don’t we sleep 失眠的原因", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "ReadingPractice/PDF/", - "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-234": { - "examId": "p2-high-234", - "dataKey": "p2-high-234", - "script": "./p2-high-234.js", - "title": "How do plants talk to each other 植物交流", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "ReadingPractice/PDF/", - "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-221": { - "examId": "p3-high-221", - "dataKey": "p3-high-221", - "script": "./p3-high-221.js", - "title": "The Animal Connection 动物联结", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "221. P3 - The Animal Connection 动物联结.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-235": { - "examId": "p2-high-235", - "dataKey": "p2-high-235", - "script": "./p2-high-235.js", - "title": "The return of the black-footed ferret 黑足鼬", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-236": { - "examId": "p2-high-236", - "dataKey": "p2-high-236", - "script": "./p2-high-236.js", - "title": "War of the Plants 植物的战争", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "", - "filename": "", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p3-high-229": { - "examId": "p3-high-229", - "dataKey": "p3-high-229", - "script": "./p3-high-229.js", - "title": "All in the family 兄弟姐妹的影响", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-239": { - "examId": "p2-high-239", - "dataKey": "p2-high-239", - "script": "./p2-high-239.js", - "title": "Nanotechnology: the science of the very small 纳米科技", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-240": { - "examId": "p2-low-240", - "dataKey": "p2-low-240", - "script": "./p2-low-240.js", - "title": "Coins - the first form of money 硬币起源", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "assets/generated/reading-exams/", - "filename": "reading-practice-unified.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p1-high-240": { - "examId": "p1-high-240", - "dataKey": "p1-high-240", - "script": "./p1-high-240.js", - "title": "The Origins of Weather Forecasting 天气预报", - "category": "P1", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-242": { - "examId": "p2-low-242", - "dataKey": "p2-low-242", - "script": "./p2-low-242.js", - "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-240": { - "examId": "p3-low-240", - "dataKey": "p3-low-240", - "script": "./p3-low-240.js", - "title": "How a prehistoric predator took to the skies 翼龙飞行", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - How a prehistoric predator took to the skies.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-241": { - "examId": "p3-medium-241", - "dataKey": "p3-medium-241", - "script": "./p3-medium-241.js", - "title": "Who looks after the children in today's Britain? 育儿分工", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - Who looks after the children in today's Britain.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", - "sourceKind": "generated-reading" - } + "p1-high-01": { + "examId": "p1-high-01", + "dataKey": "p1-high-01", + "script": "./p1-high-01.js", + "title": "A Brief History of Tea 茶叶简史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/", + "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-02": { + "examId": "p1-low-02", + "dataKey": "p1-low-02", + "script": "./p1-low-02.js", + "title": "Maori Fish Hooks 毛利鱼钩", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/", + "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-03": { + "examId": "p3-high-03", + "dataKey": "p3-high-03", + "script": "./p3-high-03.js", + "title": "What makes a musical expert_ 音乐天赋", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", + "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-04": { + "examId": "p3-high-04", + "dataKey": "p3-high-04", + "script": "./p3-high-04.js", + "title": "Yawning 打呵欠", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", + "filename": "101. P3 - Yawning 打呵欠【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-05": { + "examId": "p1-high-05", + "dataKey": "p1-high-05", + "script": "./p1-high-05.js", + "title": "Katherine Mansfield 新西兰作家", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/", + "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-06": { + "examId": "p2-low-06", + "dataKey": "p2-low-06", + "script": "./p2-low-06.js", + "title": "Biomimicry 仿生学", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", + "filename": "103. P2 - Biomimicry 仿生学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-07": { + "examId": "p3-low-07", + "dataKey": "p3-low-07", + "script": "./p3-low-07.js", + "title": "Star Performers 明星员工", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", + "filename": "104. P3 - Star Performers 明星员工.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-08": { + "examId": "p2-low-08", + "dataKey": "p2-low-08", + "script": "./p2-low-08.js", + "title": "How the Petri dish supports scientific advances 培养皿", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/", + "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-09": { + "examId": "p2-high-09", + "dataKey": "p2-high-09", + "script": "./p2-high-09.js", + "title": "Early Approaches to Organisational Design 组织设计", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", + "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-10": { + "examId": "p2-medium-10", + "dataKey": "p2-medium-10", + "script": "./p2-medium-10.js", + "title": "A study of western celebrity 西方名人", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/", + "filename": "107. P2 - A study of western celebrity 西方名人【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-11": { + "examId": "p1-low-11", + "dataKey": "p1-low-11", + "script": "./p1-low-11.js", + "title": "Bovids 牛科动物", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/", + "filename": "108. P1 - Bovids 牛科动物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-12": { + "examId": "p3-low-12", + "dataKey": "p3-low-12", + "script": "./p3-low-12.js", + "title": "Humanities and the health professional 人文医学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/", + "filename": "109. P3 - Humanities and the health professional 人文医学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-13": { + "examId": "p1-low-13", + "dataKey": "p1-low-13", + "script": "./p1-low-13.js", + "title": "Report on a university drama project 大学戏剧项目报告", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", + "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-14": { + "examId": "p2-high-14", + "dataKey": "p2-high-14", + "script": "./p2-high-14.js", + "title": "Should space be explored by robots or by humans 人机太空探索", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", + "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-15": { + "examId": "p3-high-15", + "dataKey": "p3-high-15", + "script": "./p3-high-15.js", + "title": "Whale Culture 鲸鱼文化", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", + "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-16": { + "examId": "p2-high-16", + "dataKey": "p2-high-16", + "script": "./p2-high-16.js", + "title": "The Importance of Law 法律的意义", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/", + "filename": "112. P2 - The Importance of Law 法律的意义【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-17": { + "examId": "p2-high-17", + "dataKey": "p2-high-17", + "script": "./p2-high-17.js", + "title": "Herbal Medicines 新西兰草药", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/", + "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-18": { + "examId": "p3-medium-18", + "dataKey": "p3-medium-18", + "script": "./p3-medium-18.js", + "title": "Unlocking the mystery of dreams 梦的解析", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/", + "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-19": { + "examId": "p2-high-19", + "dataKey": "p2-high-19", + "script": "./p2-high-19.js", + "title": "Mind Music 脑海中的音乐(心灵音乐)", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", + "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-20": { + "examId": "p1-medium-20", + "dataKey": "p1-medium-20", + "script": "./p1-medium-20.js", + "title": "The Development of Plastics 塑料的发展史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/", + "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-21": { + "examId": "p2-high-21", + "dataKey": "p2-high-21", + "script": "./p2-high-21.js", + "title": "Stress Less 工作压力", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", + "filename": "117. P2 - Stress Less 工作压力【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-22": { + "examId": "p3-medium-22", + "dataKey": "p3-medium-22", + "script": "./p3-medium-22.js", + "title": "Neanderthal Technology 尼安德特人的生存技艺", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", + "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-23": { + "examId": "p2-high-23", + "dataKey": "p2-high-23", + "script": "./p2-high-23.js", + "title": "The Constant Evolution of the Humble Tomato 番茄的演化", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", + "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-24": { + "examId": "p1-high-24", + "dataKey": "p1-high-24", + "script": "./p1-high-24.js", + "title": "Rubber 橡胶", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/", + "filename": "12. P1 - Rubber 橡胶【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-25": { + "examId": "p2-high-25", + "dataKey": "p2-high-25", + "script": "./p2-high-25.js", + "title": "Will Eating Less Make You Live Longer 节食与长寿", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", + "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-27": { + "examId": "p1-high-27", + "dataKey": "p1-high-27", + "script": "./p1-high-27.js", + "title": "Footprints in the Mud 恐龙脚印", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", + "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-28": { + "examId": "p3-low-28", + "dataKey": "p3-low-28", + "script": "./p3-low-28.js", + "title": "Images and Places 风景与印记", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/", + "filename": "123. P3 - Images and Places 风景与印记.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-29": { + "examId": "p1-medium-29", + "dataKey": "p1-medium-29", + "script": "./p1-medium-29.js", + "title": "The extinction of the cave bear 洞熊的灭绝", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/", + "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-30": { + "examId": "p1-low-30", + "dataKey": "p1-low-30", + "script": "./p1-low-30.js", + "title": "Investing in the Future 投资未来", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", + "filename": "125. P1 - Investing in the Future 投资未来.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-31": { + "examId": "p1-high-31", + "dataKey": "p1-high-31", + "script": "./p1-high-31.js", + "title": "Dolls through the ages 玩偶的变迁史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/", + "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-32": { + "examId": "p3-high-32", + "dataKey": "p3-high-32", + "script": "./p3-high-32.js", + "title": "Science and Filmmaking 电影科学(CGI)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/", + "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-33": { + "examId": "p1-medium-33", + "dataKey": "p1-medium-33", + "script": "./p1-medium-33.js", + "title": "The Pyramid of Cestius 罗马金字塔", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", + "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-34": { + "examId": "p1-low-34", + "dataKey": "p1-low-34", + "script": "./p1-low-34.js", + "title": "The Slow Food Organization 慢食运动组织", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/", + "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-35": { + "examId": "p1-low-35", + "dataKey": "p1-low-35", + "script": "./p1-low-35.js", + "title": "Sweet Trouble 澳洲制糖产业", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/", + "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-36": { + "examId": "p3-low-36", + "dataKey": "p3-low-36", + "script": "./p3-low-36.js", + "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/", + "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-37": { + "examId": "p2-low-37", + "dataKey": "p2-low-37", + "script": "./p2-low-37.js", + "title": "Keeping the water away 洪水防控", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", + "filename": "131. P2 - Keeping the water away 洪水防控.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-38": { + "examId": "p3-low-38", + "dataKey": "p3-low-38", + "script": "./p3-low-38.js", + "title": "Research into the effects of different teaching styles 教学风格研究", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/", + "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-39": { + "examId": "p2-low-39", + "dataKey": "p2-low-39", + "script": "./p2-low-39.js", + "title": "How to be Happy 如何获得幸福", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/", + "filename": "133. P2 - How to be Happy 如何获得幸福.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-40": { + "examId": "p1-low-40", + "dataKey": "p1-low-40", + "script": "./p1-low-40.js", + "title": "Dyes and fabric dyeing 染料的历史", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/", + "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-41": { + "examId": "p2-low-41", + "dataKey": "p2-low-41", + "script": "./p2-low-41.js", + "title": "The Myth of the Eight-hour Sleep 八小时睡眠", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/", + "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-42": { + "examId": "p3-low-42", + "dataKey": "p3-low-42", + "script": "./p3-low-42.js", + "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", + "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-43": { + "examId": "p3-low-43", + "dataKey": "p3-low-43", + "script": "./p3-low-43.js", + "title": "What is social history 社会史", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", + "filename": "137. P3 - What is social history 社会史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-44": { + "examId": "p3-low-44", + "dataKey": "p3-low-44", + "script": "./p3-low-44.js", + "title": "Conformity 从众心理", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/", + "filename": "138. P3 - Conformity 从众心理.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-45": { + "examId": "p1-low-45", + "dataKey": "p1-low-45", + "script": "./p1-low-45.js", + "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", + "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-46": { + "examId": "p1-low-46", + "dataKey": "p1-low-46", + "script": "./p1-low-46.js", + "title": "Sydney Opera House 悉尼歌剧院", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", + "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-47": { + "examId": "p1-low-47", + "dataKey": "p1-low-47", + "script": "./p1-low-47.js", + "title": "The Burgess Shale fossils 伯吉斯页岩", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/", + "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-48": { + "examId": "p1-low-48", + "dataKey": "p1-low-48", + "script": "./p1-low-48.js", + "title": "The history of the guitar 吉他的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", + "filename": "141. P1 - The history of the guitar 吉他的历史.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p2-low-49": { + "examId": "p2-low-49", + "dataKey": "p2-low-49", + "script": "./p2-low-49.js", + "title": "Born to Trade 交易的本能", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/", + "filename": "142. P2 - Born to Trade 交易的本能.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-50": { + "examId": "p2-low-50", + "dataKey": "p2-low-50", + "script": "./p2-low-50.js", + "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", + "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-51": { + "examId": "p2-low-51", + "dataKey": "p2-low-51", + "script": "./p2-low-51.js", + "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/", + "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-52": { + "examId": "p1-low-52", + "dataKey": "p1-low-52", + "script": "./p1-low-52.js", + "title": "Caral an ancient South American city 卡拉尔古城", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", + "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-53": { + "examId": "p1-low-53", + "dataKey": "p1-low-53", + "script": "./p1-low-53.js", + "title": "The Early History of Olive Oil 橄榄油的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/", + "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-54": { + "examId": "p3-low-54", + "dataKey": "p3-low-54", + "script": "./p3-low-54.js", + "title": "Movement Underwater 水下运动", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", + "filename": "147. P3 - Movement Underwater 水下运动.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-55": { + "examId": "p3-low-55", + "dataKey": "p3-low-55", + "script": "./p3-low-55.js", + "title": "Improving Patient Safety 药品包装设计", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/", + "filename": "148. P3 - Improving Patient Safety 药品包装设计.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-56": { + "examId": "p3-low-56", + "dataKey": "p3-low-56", + "script": "./p3-low-56.js", + "title": "Learning to be bilingual 双语学习", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/", + "filename": "149. P3 - Learning to be bilingual 双语学习.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-57": { + "examId": "p1-medium-57", + "dataKey": "p1-medium-57", + "script": "./p1-medium-57.js", + "title": "The Blockbuster Phenomenon 博物馆爆款现象", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/", + "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-58": { + "examId": "p2-medium-58", + "dataKey": "p2-medium-58", + "script": "./p2-medium-58.js", + "title": "Insect Decision-Making 昆虫决策", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/", + "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-59": { + "examId": "p3-low-59", + "dataKey": "p3-low-59", + "script": "./p3-low-59.js", + "title": "Inside the mind of a fan 观赛心境", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/", + "filename": "151. P3 - Inside the mind of a fan 观赛心境.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-60": { + "examId": "p1-medium-60", + "dataKey": "p1-medium-60", + "script": "./p1-medium-60.js", + "title": "Sorry—who are you 脸盲症", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/", + "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-61": { + "examId": "p1-low-61", + "dataKey": "p1-low-61", + "script": "./p1-low-61.js", + "title": "Carnivorous plants 食虫植物", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/", + "filename": "153. P1 - Carnivorous plants 食虫植物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-62": { + "examId": "p2-low-62", + "dataKey": "p2-low-62", + "script": "./p2-low-62.js", + "title": "The purpose of facial expressions 面部表情", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/", + "filename": "154. P2 - The purpose of facial expressions 面部表情.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-63": { + "examId": "p1-medium-63", + "dataKey": "p1-medium-63", + "script": "./p1-medium-63.js", + "title": "A Brief History of Humans and Food 人类食物的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/", + "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-64": { + "examId": "p2-low-64", + "dataKey": "p2-low-64", + "script": "./p2-low-64.js", + "title": "New filter promises clean water for millions 新型泥土净水器", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/", + "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-65": { + "examId": "p2-low-65", + "dataKey": "p2-low-65", + "script": "./p2-low-65.js", + "title": "Boring Buildings 无聊建筑", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/", + "filename": "157. P2 - Boring Buildings 无聊建筑.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-66": { + "examId": "p3-medium-66", + "dataKey": "p3-medium-66", + "script": "./p3-medium-66.js", + "title": "Mercator - The Map Maker 地理制图师", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/", + "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-67": { + "examId": "p1-low-67", + "dataKey": "p1-low-67", + "script": "./p1-low-67.js", + "title": "Scented Plants 植物的味道", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/", + "filename": "159. P1 - Scented Plants 植物的味道.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-68": { + "examId": "p1-low-68", + "dataKey": "p1-low-68", + "script": "./p1-low-68.js", + "title": "The Clipper Races 帆船竞速", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", + "filename": "16. P1 - The Clipper Races 帆船竞速.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-69": { + "examId": "p1-low-69", + "dataKey": "p1-low-69", + "script": "./p1-low-69.js", + "title": "An important language development 楔形文字", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/", + "filename": "160. P1 - An important language development 楔形文字.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-70": { + "examId": "p1-low-70", + "dataKey": "p1-low-70", + "script": "./p1-low-70.js", + "title": "Fluorescence Deep sea discovery深海发光生物研究", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", + "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-71": { + "examId": "p3-low-71", + "dataKey": "p3-low-71", + "script": "./p3-low-71.js", + "title": "Sea Change for Salinity 土地盐碱化", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/", + "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-72": { + "examId": "p1-low-72", + "dataKey": "p1-low-72", + "script": "./p1-low-72.js", + "title": "How to find your way out of a food desert 城市食物荒漠", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/", + "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-73": { + "examId": "p2-low-73", + "dataKey": "p2-low-73", + "script": "./p2-low-73.js", + "title": "The Power of Smell 嗅觉的力量", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/", + "filename": "164. P2 - The Power of Smell 嗅觉的力量.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-74": { + "examId": "p3-low-74", + "dataKey": "p3-low-74", + "script": "./p3-low-74.js", + "title": "The Placebo Effect5 安慰剂效应", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/", + "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-75": { + "examId": "p2-low-75", + "dataKey": "p2-low-75", + "script": "./p2-low-75.js", + "title": "Lean Production Innovation 精益生产", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", + "filename": "166. P2 - Lean Production Innovation 精益生产.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-76": { + "examId": "p3-low-76", + "dataKey": "p3-low-76", + "script": "./p3-low-76.js", + "title": "Sign, Baby, Sign! 美国手语", + "category": "P3", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/", + "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-77": { + "examId": "p2-low-77", + "dataKey": "p2-low-77", + "script": "./p2-low-77.js", + "title": "Mammoth Kill 猛犸象的灭绝", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", + "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-78": { + "examId": "p3-low-78", + "dataKey": "p3-low-78", + "script": "./p3-low-78.js", + "title": "The Costs of Brand Loyalty 品牌忠诚的代价", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/", + "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-79": { + "examId": "p1-high-79", + "dataKey": "p1-high-79", + "script": "./p1-high-79.js", + "title": "The Development of The Silk Industry 丝绸产业发展", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/", + "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-80": { + "examId": "p1-low-80", + "dataKey": "p1-low-80", + "script": "./p1-low-80.js", + "title": "The unsung sense 被低估的嗅觉", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", + "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-81": { + "examId": "p1-low-81", + "dataKey": "p1-low-81", + "script": "./p1-low-81.js", + "title": "Salt 盐的历史", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/", + "filename": "171. P1 - Salt 盐的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-82": { + "examId": "p1-high-82", + "dataKey": "p1-high-82", + "script": "./p1-high-82.js", + "title": "Think Small 微观科学", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", + "filename": "172. P1 - Think Small 微观科学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-83": { + "examId": "p3-low-83", + "dataKey": "p3-low-83", + "script": "./p3-low-83.js", + "title": "1018纸笔 Looking for inspiration 寻找灵感", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/", + "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-84": { + "examId": "p1-low-84", + "dataKey": "p1-low-84", + "script": "./p1-low-84.js", + "title": "Why good ideas fail TF公司", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", + "filename": "174. P1 - Why good ideas fail TF公司.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-85": { + "examId": "p3-low-85", + "dataKey": "p3-low-85", + "script": "./p3-low-85.js", + "title": "Music soothes and awes 音乐疗愈", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/", + "filename": "175. P3 - Music soothes and awes 音乐疗愈.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-86": { + "examId": "p2-medium-86", + "dataKey": "p2-medium-86", + "script": "./p2-medium-86.js", + "title": "Urban Regeneration 柏林公园改造", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", + "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-87": { + "examId": "p2-low-87", + "dataKey": "p2-low-87", + "script": "./p2-low-87.js", + "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/", + "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-88": { + "examId": "p3-low-88", + "dataKey": "p3-low-88", + "script": "./p3-low-88.js", + "title": "1025纸笔Translating a key to international understanding 翻译的艺术", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/", + "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-89": { + "examId": "p3-high-89", + "dataKey": "p3-high-89", + "script": "./p3-high-89.js", + "title": "Looking at daily life in ancient Rome 古罗马的日常", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/", + "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-90": { + "examId": "p1-high-90", + "dataKey": "p1-high-90", + "script": "./p1-high-90.js", + "title": "The History of Tea 茶叶的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/", + "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-91": { + "examId": "p2-high-91", + "dataKey": "p2-high-91", + "script": "./p2-high-91.js", + "title": "Australia’s camouflaged creatures 澳洲伪装生物", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", + "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-92": { + "examId": "p1-high-92", + "dataKey": "p1-high-92", + "script": "./p1-high-92.js", + "title": "Dust and the American West 美国西部尘埃", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", + "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-93": { + "examId": "p2-medium-93", + "dataKey": "p2-medium-93", + "script": "./p2-medium-93.js", + "title": "Antarctic research 南极考察", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", + "filename": "182. P2 - Antarctic research 南极考察.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-94": { + "examId": "p2-low-94", + "dataKey": "p2-low-94", + "script": "./p2-low-94.js", + "title": "The importance of being playful 玩耍的重要性", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/", + "filename": "183. P2 - The importance of being playful 玩耍的重要性.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-95": { + "examId": "p3-low-95", + "dataKey": "p3-low-95", + "script": "./p3-low-95.js", + "title": "The strange world of sight 奇异的视觉世界", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", + "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-96": { + "examId": "p2-low-96", + "dataKey": "p2-low-96", + "script": "./p2-low-96.js", + "title": "[Pretest] Why Do We Need Sleep 睡眠的目的", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/", + "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-97": { + "examId": "p3-low-97", + "dataKey": "p3-low-97", + "script": "./p3-low-97.js", + "title": "Saving languages 拯救濒危语言", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/", + "filename": "186. P3 - Saving languages 拯救濒危语言.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-98": { + "examId": "p3-low-98", + "dataKey": "p3-low-98", + "script": "./p3-low-98.js", + "title": "Petrol power an eco-revolution 交通的革命", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/", + "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-99": { + "examId": "p1-low-99", + "dataKey": "p1-low-99", + "script": "./p1-low-99.js", + "title": "The history of the bar code 条形码的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", + "filename": "188. P1 - The history of the bar code 条形码的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-100": { + "examId": "p3-low-100", + "dataKey": "p3-low-100", + "script": "./p3-low-100.js", + "title": "Mirror 镜子研究", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/", + "filename": "189. P3 - Mirror 镜子研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-101": { + "examId": "p1-high-101", + "dataKey": "p1-high-101", + "script": "./p1-high-101.js", + "title": "The Impact of the Potato 土豆的影响", + "category": "P1", + "frequency": "高频", + "difficultyScore": 1, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/", + "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-102": { + "examId": "p2-low-102", + "dataKey": "p2-low-102", + "script": "./p2-low-102.js", + "title": "The power of music 音乐的力量", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/", + "filename": "190. P2 - The power of music 音乐的力量.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-103": { + "examId": "p2-low-103", + "dataKey": "p2-low-103", + "script": "./p2-low-103.js", + "title": "The economic effect of climate 气候对经济的影响", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", + "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-104": { + "examId": "p2-low-104", + "dataKey": "p2-low-104", + "script": "./p2-low-104.js", + "title": "1115纸笔Should we stop eating meat 是否应该吃素", + "category": "P2", + "frequency": "low", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", + "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-105": { + "examId": "p1-high-105", + "dataKey": "p1-high-105", + "script": "./p1-high-105.js", + "title": "A survivor’s story 新西兰猫头鹰", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/", + "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-106": { + "examId": "p1-low-106", + "dataKey": "p1-low-106", + "script": "./p1-low-106.js", + "title": "The Importance of Business Cards 名片的重要性", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/", + "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-107": { + "examId": "p1-low-107", + "dataKey": "p1-low-107", + "script": "./p1-low-107.js", + "title": "The life of Beatrix Potter 彼得兔作家", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", + "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-108": { + "examId": "p1-low-108", + "dataKey": "p1-low-108", + "script": "./p1-low-108.js", + "title": "The nature of Yawning 打哈欠的本质", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/", + "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-109": { + "examId": "p1-low-109", + "dataKey": "p1-low-109", + "script": "./p1-low-109.js", + "title": "The Origin of Paper 造纸术起源", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", + "filename": "23. P1 - The Origin of Paper 造纸术起源.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-110": { + "examId": "p1-high-110", + "dataKey": "p1-high-110", + "script": "./p1-high-110.js", + "title": "The Pearls 珍珠", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/", + "filename": "24. P1 - The Pearls 珍珠【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-111": { + "examId": "p1-low-111", + "dataKey": "p1-low-111", + "script": "./p1-low-111.js", + "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/", + "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-112": { + "examId": "p1-low-112", + "dataKey": "p1-low-112", + "script": "./p1-low-112.js", + "title": "The Tuatara of New Zealand 新西兰蜥蜴", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/", + "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-113": { + "examId": "p1-low-113", + "dataKey": "p1-low-113", + "script": "./p1-low-113.js", + "title": "Thomas Young The last man who knew everything 托马斯·杨", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", + "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-114": { + "examId": "p1-low-114", + "dataKey": "p1-low-114", + "script": "./p1-low-114.js", + "title": "Triumph of the City 城市的胜利", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 1.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", + "filename": "28. P1 - Triumph of the City 城市的胜利.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-115": { + "examId": "p1-medium-115", + "dataKey": "p1-medium-115", + "script": "./p1-medium-115.js", + "title": "Tunnelling under the Thames", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", + "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-116": { + "examId": "p1-low-116", + "dataKey": "p1-low-116", + "script": "./p1-low-116.js", + "title": "Advertising Needs Attention 广告的吸引力", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/", + "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-117": { + "examId": "p1-medium-117", + "dataKey": "p1-medium-117", + "script": "./p1-medium-117.js", + "title": "What Lucy Taught Us 露西化石", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", + "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-118": { + "examId": "p1-high-118", + "dataKey": "p1-high-118", + "script": "./p1-high-118.js", + "title": "William Gilbert and Magnetism 电磁学之父", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/", + "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-119": { + "examId": "p1-medium-119", + "dataKey": "p1-medium-119", + "script": "./p1-medium-119.js", + "title": "Wood 新西兰木材产业", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", + "filename": "32. P1 - Wood 新西兰木材产业【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-120": { + "examId": "p2-high-120", + "dataKey": "p2-high-120", + "script": "./p2-high-120.js", + "title": "A new look for Talbot Park 奥克兰社区改造", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/", + "filename": "ai_studio_code (9).html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-121": { + "examId": "p2-medium-121", + "dataKey": "p2-medium-121", + "script": "./p2-medium-121.js", + "title": "A unique golden textile 蜘蛛丝", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/", + "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-122": { + "examId": "p2-low-122", + "dataKey": "p2-low-122", + "script": "./p2-low-122.js", + "title": "Biophilic Design 亲自然设计", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", + "filename": "35. P2 - Biophilic Design 亲自然设计.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-123": { + "examId": "p2-high-123", + "dataKey": "p2-high-123", + "script": "./p2-high-123.js", + "title": "Bird Migration 鸟类迁徙", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/", + "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-124": { + "examId": "p2-high-124", + "dataKey": "p2-high-124", + "script": "./p2-high-124.js", + "title": "Corporate Social Responsibility 企业社会责任", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", + "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-125": { + "examId": "p2-low-125", + "dataKey": "p2-low-125", + "script": "./p2-low-125.js", + "title": "Egypt’s ancient boat-builders 古埃及造船", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/", + "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-126": { + "examId": "p2-medium-126", + "dataKey": "p2-medium-126", + "script": "./p2-medium-126.js", + "title": "How are deserts formed 沙漠成因", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/", + "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-127": { + "examId": "p1-low-127", + "dataKey": "p1-low-127", + "script": "./p1-low-127.js", + "title": "Ambergris 龙涎香", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", + "filename": "4. P1 - Ambergris 龙涎香.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-128": { + "examId": "p2-high-128", + "dataKey": "p2-high-128", + "script": "./p2-high-128.js", + "title": "How Well Do We Concentrate_ 多任务处理", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/", + "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-129": { + "examId": "p2-medium-129", + "dataKey": "p2-medium-129", + "script": "./p2-medium-129.js", + "title": "Intelligent behaviour in birds 鸟类智慧行为", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", + "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-130": { + "examId": "p2-high-130", + "dataKey": "p2-high-130", + "script": "./p2-high-130.js", + "title": "Investment in shares versus investment in other assets 回报数据分析", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", + "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-131": { + "examId": "p2-high-131", + "dataKey": "p2-high-131", + "script": "./p2-high-131.js", + "title": "Learning from the Romans 罗马混凝土", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/", + "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-132": { + "examId": "p2-low-132", + "dataKey": "p2-low-132", + "script": "./p2-low-132.js", + "title": "Orientation of Birds 鸟类的定位能力", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/", + "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-133": { + "examId": "p2-high-133", + "dataKey": "p2-high-133", + "script": "./p2-high-133.js", + "title": "Playing soccer 街头足球", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/", + "filename": "45. P2 - Playing soccer 街头足球【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-134": { + "examId": "p2-high-134", + "dataKey": "p2-high-134", + "script": "./p2-high-134.js", + "title": "Roller coaster 过山车", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/", + "filename": "46. P2 - Roller coaster 过山车【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-135": { + "examId": "p2-low-135", + "dataKey": "p2-low-135", + "script": "./p2-low-135.js", + "title": "Skyscraper Farming 摩天大楼种植", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", + "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-136": { + "examId": "p2-high-136", + "dataKey": "p2-high-136", + "script": "./p2-high-136.js", + "title": "Solving the problem of waste disposal 垃圾处理", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/", + "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-137": { + "examId": "p2-high-137", + "dataKey": "p2-high-137", + "script": "./p2-high-137.js", + "title": "Surviving city life 动物适应城市", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", + "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-138": { + "examId": "p1-low-138", + "dataKey": "p1-low-138", + "script": "./p1-low-138.js", + "title": "Australian artist Margaret Preston 澳大利亚艺术家", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/", + "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-139": { + "examId": "p2-high-139", + "dataKey": "p2-high-139", + "script": "./p2-high-139.js", + "title": "The conquest of malaria in Italy 意大利疟疾防治", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", + "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-140": { + "examId": "p2-low-140", + "dataKey": "p2-low-140", + "script": "./p2-low-140.js", + "title": "The dingo debate 澳洲野犬", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/", + "filename": "51. P2 - The dingo debate 澳洲野犬.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-141": { + "examId": "p2-high-141", + "dataKey": "p2-high-141", + "script": "./p2-high-141.js", + "title": "The fascinating world of attine ants 切叶蚁", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/", + "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-142": { + "examId": "p2-low-142", + "dataKey": "p2-low-142", + "script": "./p2-low-142.js", + "title": "The fashion industry 时尚产业", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", + "filename": "53. P2 - The fashion industry 时尚产业.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-143": { + "examId": "p2-low-143", + "dataKey": "p2-low-143", + "script": "./p2-low-143.js", + "title": "The impact of invasive species 入侵物种的影响", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", + "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-144": { + "examId": "p2-medium-144", + "dataKey": "p2-medium-144", + "script": "./p2-medium-144.js", + "title": "The plan to bring an asteroid to Earth 捕获小行星", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/", + "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-145": { + "examId": "p2-high-145", + "dataKey": "p2-high-145", + "script": "./p2-high-145.js", + "title": "The return of monkey life 猴群回归", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", + "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-146": { + "examId": "p2-medium-146", + "dataKey": "p2-medium-146", + "script": "./p2-medium-146.js", + "title": "The Tasmanian Tiger 袋狼", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/", + "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-147": { + "examId": "p2-low-147", + "dataKey": "p2-low-147", + "script": "./p2-low-147.js", + "title": "Who wrote Shakespeare's plays 莎士比亚", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", + "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-148": { + "examId": "p2-low-148", + "dataKey": "p2-low-148", + "script": "./p2-low-148.js", + "title": "Why do we need the arts_ 艺术的意义", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/", + "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-149": { + "examId": "p1-low-149", + "dataKey": "p1-low-149", + "script": "./p1-low-149.js", + "title": "Categorizing societies 社会分类", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/", + "filename": "6. P1 - Categorizing societies 社会分类html.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-150": { + "examId": "p3-high-150", + "dataKey": "p3-high-150", + "script": "./p3-high-150.js", + "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/", + "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-151": { + "examId": "p3-low-151", + "dataKey": "p3-low-151", + "script": "./p3-low-151.js", + "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/", + "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-152": { + "examId": "p3-medium-152", + "dataKey": "p3-medium-152", + "script": "./p3-medium-152.js", + "title": "Charles Darwin and Evolutionary Psychology 进化心理学", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", + "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-153": { + "examId": "p3-low-153", + "dataKey": "p3-low-153", + "script": "./p3-low-153.js", + "title": "Crossing the Threshold 奥克兰美术馆", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/", + "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-154": { + "examId": "p3-medium-154", + "dataKey": "p3-medium-154", + "script": "./p3-medium-154.js", + "title": "Decisions, Decisions 决策之间", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/", + "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-155": { + "examId": "p3-medium-155", + "dataKey": "p3-medium-155", + "script": "./p3-medium-155.js", + "title": "Does class size matter_ 课堂规模", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", + "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-156": { + "examId": "p3-high-156", + "dataKey": "p3-high-156", + "script": "./p3-high-156.js", + "title": "Elephant Communication 大象交流", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/", + "filename": "66. P3 - Elephant Communication 大象交流【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-157": { + "examId": "p3-high-157", + "dataKey": "p3-high-157", + "script": "./p3-high-157.js", + "title": "Flower Power 鲜花的力量(花之力)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", + "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf", + "sourceKind": "generated-reading" + }, + "p3-low-158": { + "examId": "p3-low-158", + "dataKey": "p3-low-158", + "script": "./p3-low-158.js", + "title": "Game theory 博弈论", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/", + "filename": "68. P3 - Game theory 博弈论.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-159": { + "examId": "p3-high-159", + "dataKey": "p3-high-159", + "script": "./p3-high-159.js", + "title": "Grimm’s Fairy Tales 格林童话", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/", + "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-160": { + "examId": "p1-low-160", + "dataKey": "p1-low-160", + "script": "./p1-low-160.js", + "title": "Chili peppers 辣椒的历史", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/", + "filename": "7. P1 - Chili peppers 辣椒的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-161": { + "examId": "p3-high-161", + "dataKey": "p3-high-161", + "script": "./p3-high-161.js", + "title": "Insect-inspired robots 昆虫机器人", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/", + "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-162": { + "examId": "p3-medium-162", + "dataKey": "p3-medium-162", + "script": "./p3-medium-162.js", + "title": "Jean Piaget (1896–1980) 让·皮亚杰", + "category": "P3", + "frequency": "low", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", + "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-163": { + "examId": "p3-low-163", + "dataKey": "p3-low-163", + "script": "./p3-low-163.js", + "title": "Keeping the Fun in Funfairs 游乐场设计科学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/", + "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-164": { + "examId": "p3-high-164", + "dataKey": "p3-high-164", + "script": "./p3-high-164.js", + "title": "Language Strategy in Multinational Companies 跨国公司语言策略", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/", + "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-165": { + "examId": "p3-low-165", + "dataKey": "p3-low-165", + "script": "./p3-low-165.js", + "title": "Let’s teach them how to teach 教他们如何教学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/", + "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-166": { + "examId": "p3-low-166", + "dataKey": "p3-low-166", + "script": "./p3-low-166.js", + "title": "Life on Mars_ 火星地球化改造", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", + "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-167": { + "examId": "p3-high-167", + "dataKey": "p3-high-167", + "script": "./p3-high-167.js", + "title": "Living dunes 流动沙丘", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/", + "filename": "76. P3 - Living dunes 流动沙丘【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-168": { + "examId": "p3-medium-168", + "dataKey": "p3-medium-168", + "script": "./p3-medium-168.js", + "title": "Marketing and the information age 信息时代营销", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", + "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-169": { + "examId": "p3-medium-169", + "dataKey": "p3-medium-169", + "script": "./p3-medium-169.js", + "title": "(无题目) Music Language We All Speak 音乐语言", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", + "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-170": { + "examId": "p3-high-170", + "dataKey": "p3-high-170", + "script": "./p3-high-170.js", + "title": "Pacific Navigation and Voyaging 太平洋航海", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/", + "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-171": { + "examId": "p1-high-171", + "dataKey": "p1-high-171", + "script": "./p1-high-171.js", + "title": "Fishbourne Roman Palace 罗马宫殿", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/", + "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-172": { + "examId": "p3-low-172", + "dataKey": "p3-low-172", + "script": "./p3-low-172.js", + "title": "Rebranding art museums 博物馆品牌重塑", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/", + "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-173": { + "examId": "p3-high-173", + "dataKey": "p3-high-173", + "script": "./p3-high-173.js", + "title": "Robert Louis Stevenson", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", + "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-174": { + "examId": "p3-high-174", + "dataKey": "p3-high-174", + "script": "./p3-high-174.js", + "title": "Some views on the use of headphones 耳机使用", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/", + "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-175": { + "examId": "p3-low-175", + "dataKey": "p3-low-175", + "script": "./p3-low-175.js", + "title": "Termite Mounds 白蚁丘", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/", + "filename": "83. P3 - Termite Mounds 白蚁丘.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-176": { + "examId": "p3-medium-176", + "dataKey": "p3-medium-176", + "script": "./p3-medium-176.js", + "title": "The Analysis of Fear 猴子恐惧实验", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", + "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-177": { + "examId": "p3-medium-177", + "dataKey": "p3-medium-177", + "script": "./p3-medium-177.js", + "title": "The Art of Deception 欺骗的艺术", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/", + "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-178": { + "examId": "p3-high-178", + "dataKey": "p3-high-178", + "script": "./p3-high-178.js", + "title": "The benefits of learning an instrument 学乐器的好处", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/", + "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-179": { + "examId": "p3-medium-179", + "dataKey": "p3-medium-179", + "script": "./p3-medium-179.js", + "title": "The Exploration of Mars 火星探索", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/", + "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-180": { + "examId": "p3-high-180", + "dataKey": "p3-high-180", + "script": "./p3-high-180.js", + "title": "The fluoridation controversy 氟化水争议", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/", + "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-181": { + "examId": "p3-high-181", + "dataKey": "p3-high-181", + "script": "./p3-high-181.js", + "title": "The Fruit Book 果实之书", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/", + "filename": "89. P3 - The Fruit Book 果实之书【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-182": { + "examId": "p1-medium-182", + "dataKey": "p1-medium-182", + "script": "./p1-medium-182.js", + "title": "Listening to the Ocean 海洋探测", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", + "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-183": { + "examId": "p3-medium-183", + "dataKey": "p3-medium-183", + "script": "./p3-medium-183.js", + "title": "The hazards of multitasking 多任务处理", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", + "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-184": { + "examId": "p3-high-184", + "dataKey": "p3-high-184", + "script": "./p3-high-184.js", + "title": "The New Zealand writer Margaret Mahy 新西兰女作家", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/", + "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-185": { + "examId": "p3-medium-185", + "dataKey": "p3-medium-185", + "script": "./p3-medium-185.js", + "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/", + "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-186": { + "examId": "p3-low-186", + "dataKey": "p3-low-186", + "script": "./p3-low-186.js", + "title": "The Robbers Cave Study (山洞)群体行为实验", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", + "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-187": { + "examId": "p3-low-187", + "dataKey": "p3-low-187", + "script": "./p3-low-187.js", + "title": "The science of sleep 睡眠的科学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/", + "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-188": { + "examId": "p3-medium-188", + "dataKey": "p3-medium-188", + "script": "./p3-medium-188.js", + "title": "The Significant Role of Mother Tongue in Education 母语教育", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/", + "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-189": { + "examId": "p3-high-189", + "dataKey": "p3-high-189", + "script": "./p3-high-189.js", + "title": "The tuatara – past and future 新西兰蜥蜴", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/", + "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-190": { + "examId": "p3-low-190", + "dataKey": "p3-low-190", + "script": "./p3-low-190.js", + "title": "The value of literary prizes 文学奖项的价值", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/", + "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-191": { + "examId": "p3-medium-191", + "dataKey": "p3-medium-191", + "script": "./p3-medium-191.js", + "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/", + "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-192": { + "examId": "p3-high-192", + "dataKey": "p3-high-192", + "script": "./p3-high-192.js", + "title": "Voynich Manuscript 伏尼契手稿", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/", + "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-200": { + "examId": "p1-high-200", + "dataKey": "p1-high-200", + "script": "./p1-high-200.js", + "title": "Australia’s Airborne Dentists 澳洲飞行牙医", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "三月/1.P1 高频/", + "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-211": { + "examId": "p1-high-211", + "dataKey": "p1-high-211", + "script": "./p1-high-211.js", + "title": "Ahead of its time 新西兰头骨", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "三月/1.P1 高频/", + "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-216": { + "examId": "p1-high-216", + "dataKey": "p1-high-216", + "script": "./p1-high-216.js", + "title": "Australia’s cane toad problem 澳洲蟾蜍", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "三月/1.P1 高频/", + "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-194": { + "examId": "p1-high-194", + "dataKey": "p1-high-194", + "script": "./p1-high-194.js", + "title": "The history of the British wool industry 英国羊毛产业的历史", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "三月/2.P1 次高频/", + "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-222": { + "examId": "p2-low-222", + "dataKey": "p2-low-222", + "script": "./p2-low-222.js", + "title": "Ideal Homes 理想居所", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "三月/", + "filename": "222. P2 - Ideal Homes 理想居所.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-223": { + "examId": "p1-low-223", + "dataKey": "p1-low-223", + "script": "./p1-low-223.js", + "title": "Effect and Cause 湖泊海啸研究", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "三月/", + "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-201": { + "examId": "p2-high-201", + "dataKey": "p2-high-201", + "script": "./p2-high-201.js", + "title": "Multi-tasking and the brain 大脑与多任务处理", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "三月/3.P2 高频/", + "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-217": { + "examId": "p2-medium-217", + "dataKey": "p2-medium-217", + "script": "./p2-medium-217.js", + "title": "A mechanical friend for children 孩子的机器人朋友", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/3.P2 高频/", + "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-192": { + "examId": "p2-high-192", + "dataKey": "p2-high-192", + "script": "./p2-high-192.js", + "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", + "category": "P2", + "frequency": "low", + "difficultyScore": 3.5, + "path": "三月/4.P2 次高频/", + "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-209": { + "examId": "p2-medium-209", + "dataKey": "p2-medium-209", + "script": "./p2-medium-209.js", + "title": "Decision Fatigue 决策疲劳", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-213": { + "examId": "p2-medium-213", + "dataKey": "p2-medium-213", + "script": "./p2-medium-213.js", + "title": "Growing more for less 卫星农业", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "三月/4.P2 次高频/", + "filename": "213. P2 - Growing more for less 卫星农业【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-051": { + "examId": "p2-low-051", + "dataKey": "p2-low-051", + "script": "./p2-low-051.js", + "title": "The dingo debate 澳洲野犬_澳洲野狗", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-058": { + "examId": "p2-medium-058", + "dataKey": "p2-medium-058", + "script": "./p2-medium-058.js", + "title": "Who wrote Shakespeare's plays 莎士比亚", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-204": { + "examId": "p3-high-204", + "dataKey": "p3-high-204", + "script": "./p3-high-204.js", + "title": "When people are ‘deaf’ to music 失乐症", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-206": { + "examId": "p3-high-206", + "dataKey": "p3-high-206", + "script": "./p3-high-206.js", + "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-212": { + "examId": "p3-high-212", + "dataKey": "p3-high-212", + "script": "./p3-high-212.js", + "title": "Children’s literature studies today 儿童文学", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-218": { + "examId": "p3-high-218", + "dataKey": "p3-high-218", + "script": "./p3-high-218.js", + "title": "The Causes of Linguistic Change 语音的演变", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-219": { + "examId": "p3-low-219", + "dataKey": "p3-low-219", + "script": "./p3-low-219.js", + "title": "The origin of language 语言的起源", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "219. P3 - The origin of language 语言的起源.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-999": { + "examId": "p3-low-999", + "dataKey": "p3-low-999", + "script": "./p3-low-999.js", + "title": "Risk taking", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "P3 - Risk taking.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-medium-197": { + "examId": "p3-medium-197", + "dataKey": "p3-medium-197", + "script": "./p3-medium-197.js", + "title": "Australia’s Megafauna Controversy 巨兽灭绝", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/6.P3 次高频/", + "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-198": { + "examId": "p3-low-198", + "dataKey": "p3-low-198", + "script": "./p3-low-198.js", + "title": "Child’s Play in Medieval England 中世纪的游戏", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "三月/6.P3 次高频/", + "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-078": { + "examId": "p3-low-078", + "dataKey": "p3-low-078", + "script": "./p3-low-078.js", + "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "三月/6.P3 次高频/", + "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-227": { + "examId": "p1-high-227", + "dataKey": "p1-high-227", + "script": "./p1-high-227.js", + "title": "The Whale Goes to Court 鲸鱼油", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "ReadingPractice/PDF/", + "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-225": { + "examId": "p2-high-225", + "dataKey": "p2-high-225", + "script": "./p2-high-225.js", + "title": "The problem of graffiti 涂鸦之困", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "ReadingPractice/PDF/", + "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-228": { + "examId": "p3-high-228", + "dataKey": "p3-high-228", + "script": "./p3-high-228.js", + "title": "On art and artists 艺术与艺术家", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "ReadingPractice/PDF/", + "filename": "228. P3 - On art and artists 艺术与艺术家.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-229": { + "examId": "p1-high-229", + "dataKey": "p1-high-229", + "script": "./p1-high-229.js", + "title": "New Understanding of Giraffes in the Wild 野生长颈鹿", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "ReadingPractice/PDF/", + "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-230": { + "examId": "p1-high-230", + "dataKey": "p1-high-230", + "script": "./p1-high-230.js", + "title": "The History of the Pencil 铅笔的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 1.5, + "path": "ReadingPractice/PDF/", + "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-231": { + "examId": "p1-high-231", + "dataKey": "p1-high-231", + "script": "./p1-high-231.js", + "title": "The History of the Pencil 铅笔的历史(流程图版)", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "ReadingPractice/PDF/", + "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", + "sourceKind": "generated-reading" + }, + "p2-high-232": { + "examId": "p2-high-232", + "dataKey": "p2-high-232", + "script": "./p2-high-232.js", + "title": "The origin and development of applause 掌声的历史", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "ReadingPractice/PDF/", + "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-233": { + "examId": "p2-high-233", + "dataKey": "p2-high-233", + "script": "./p2-high-233.js", + "title": "Why don’t we sleep 失眠的原因", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "ReadingPractice/PDF/", + "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-234": { + "examId": "p2-high-234", + "dataKey": "p2-high-234", + "script": "./p2-high-234.js", + "title": "How do plants talk to each other 植物交流", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "ReadingPractice/PDF/", + "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-221": { + "examId": "p3-high-221", + "dataKey": "p3-high-221", + "script": "./p3-high-221.js", + "title": "The Animal Connection 动物联结", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "221. P3 - The Animal Connection 动物联结.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-235": { + "examId": "p2-high-235", + "dataKey": "p2-high-235", + "script": "./p2-high-235.js", + "title": "The return of the black-footed ferret 黑足鼬", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-236": { + "examId": "p2-high-236", + "dataKey": "p2-high-236", + "script": "./p2-high-236.js", + "title": "War of the Plants 植物的战争", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "", + "filename": "", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-high-229": { + "examId": "p3-high-229", + "dataKey": "p3-high-229", + "script": "./p3-high-229.js", + "title": "All in the family 兄弟姐妹的影响", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-239": { + "examId": "p2-high-239", + "dataKey": "p2-high-239", + "script": "./p2-high-239.js", + "title": "Nanotechnology: the science of the very small 纳米科技", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-240": { + "examId": "p2-low-240", + "dataKey": "p2-low-240", + "script": "./p2-low-240.js", + "title": "Coins - the first form of money 硬币起源", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "assets/generated/reading-exams/", + "filename": "reading-practice-unified.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p1-high-240": { + "examId": "p1-high-240", + "dataKey": "p1-high-240", + "script": "./p1-high-240.js", + "title": "The Origins of Weather Forecasting 天气预报", + "category": "P1", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-242": { + "examId": "p2-low-242", + "dataKey": "p2-low-242", + "script": "./p2-low-242.js", + "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-240": { + "examId": "p3-low-240", + "dataKey": "p3-low-240", + "script": "./p3-low-240.js", + "title": "How a prehistoric predator took to the skies 翼龙飞行", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - How a prehistoric predator took to the skies.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-241": { + "examId": "p3-medium-241", + "dataKey": "p3-medium-241", + "script": "./p3-medium-241.js", + "title": "Who looks after the children in today's Britain? 育儿分工", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - Who looks after the children in today's Britain.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-243": { + "examId": "p2-medium-243", + "dataKey": "p2-medium-243", + "script": "./p2-medium-243.js", + "title": "The internal body clock", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "", + "filename": "", + "hasHtml": false, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-medium-244": { + "examId": "p3-medium-244", + "dataKey": "p3-medium-244", + "script": "./p3-medium-244.js", + "title": "Look who was talking", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "", + "filename": "", + "hasHtml": false, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + } + }; function clonePathRoot() { diff --git a/assets/generated/reading-exams/p1-low-84.js b/assets/generated/reading-exams/p1-low-84.js index 60c025f0..976ba586 100644 --- a/assets/generated/reading-exams/p1-low-84.js +++ b/assets/generated/reading-exams/p1-low-84.js @@ -9,7 +9,7 @@ "meta": { "title": "Why good ideas fail TF公司", "category": "P1", - "frequency": "low", + "frequency": "次高频", "pdfFilename": "174. P1 - Why good ideas fail TF公司.pdf", "legacyPath": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", "legacyFilename": "174. P1 - Why good ideas fail TF公司.html", diff --git a/assets/generated/reading-exams/p1-medium-115.js b/assets/generated/reading-exams/p1-medium-115.js index 8da9bd38..2e03ffdf 100644 --- a/assets/generated/reading-exams/p1-medium-115.js +++ b/assets/generated/reading-exams/p1-medium-115.js @@ -7,20 +7,20 @@ "schemaVersion": "ReadingExamSourceV1", "examId": "p1-medium-115", "meta": { - "title": "Tunnelling under the Thames 泰晤士河隧道", + "title": "Tunnelling under the Thames", "category": "P1", "frequency": "medium", "pdfFilename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.pdf", "legacyPath": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", "legacyFilename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", - "questionIntroHtml": "

Questions 1–8

" + "questionIntroHtml": "

Questions 1–13

" }, "passage": { "blocks": [ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n \n

Tunnelling under the Thames

\n \n

The first tunnel ever to be built under a major river was the tunnel under London's River Thames.

\n

At the beginning of the 19th century, the port of London was the busiest in the world. Cargoes that had travelled thousands of miles and survived all the hazards of the sea were unloaded on the banks of the Thames, only for their owners to discover that the most frustrating portion of their journey lay ahead. Consignments intended for the southern parts of Britain had to be lifted onto horse carts, pulled through the docks and across London Bridge, built in the 12th century and as impractical as its early date implies. By 1820, London Bridge had become the centre of the world's largest traffic jam.

\n

It was an intolerable situation, and it was clear that if private enterprise could build another crossing closer to the docks, there would be good money to be made in tolls paid by users. Another bridge was out of the question, as this would deny sailing ships access to the city centre, and ambitious men turned their thoughts to tunnelling beneath the Thames instead. This was not such an obvious idea as it might appear. Although increasing demand for coal had meant a great many tunnels had been dug in mines in Britain, working methods remained primitive; tunnels were dug by men with simple tools, by candlelight.

\n

However, in 1807 a group of businessmen set themselves up as the Thames Archway Company. Their ambition was to tunnel below the Thames, but there was little to guide them as there had been no previous attempt to do this. Their chief engineer was Richard Trevithick, designer of the world's first high-pressure steam engine. His men made progress at the beginning, but then things began to go disastrously wrong, with muddy soil pouring into the tunnel. Eventually, the Thames Archway Company had had enough. Its funds were exhausted, Trevithick was sick from exposure to the river water, and its efforts had proved only that a passage under the river exceeded the limits of contemporary mining technology.

\n

At that time, the only machines used in mines were pumps. It took a man of genius to recognise that a different sort of machine was needed—a machine that could prevent the roof and walls of a tunnel from collapsing. This man was Marc Brunel, a Frenchman who had become one of the most prominent engineers in Britain. Not long after the failure of the Thames Archway Company, Brunel saw a rotten piece of wood lying on the river bank. Examining the wood through a magnifying glass, he observed it was infested with creatures that looked like worms. Brunel realised that, as they tunnelled through the wood, they pushed chewed fibres into their mouths, digested them, then excreted a hard substance that lined the new tunnel. He saw that the worm's digging technique could be adapted to a new way of tunnelling.

\n

His insight led him to invent a device that has been used, in one form or another, in most major tunnels built since—the tunnelling shield. It consisted of a heavy iron frame that could be pushed forward a few inches at a time. The front of the frame was made up of a series of iron plates that could be folded back to allow miners to dig the ground ahead. Behind these plates was a wall of further iron plates pressed against the tunnel face and supported on horizontal wooden planks to prevent collapse. It was a complex and rather cumbersome machine and not easy to use, but it promised to protect the miners from the worst of the river's water. Brunel's team carefully examined earth samples taken from beneath the riverbed and decided to dig the tunnel close to the muddy river bottom, where they expected to find clay—a more solid and safer substance to dig through than the sand found deeper down.

\n

Brunel began work on his tunnel in 1825, but the problems of such an operation soon became apparent. Although the shield itself worked well, water began to drip into the tunnel. This was more of an annoyance than a danger while the pump was working, but the machine proved unreliable and sometimes failed altogether. When the pump broke down, work had to stop as the tunnel quickly flooded. There were occasions when the miners had to abandon their tools and flee for their lives. Even when Brunel's men were able to work, they faced the constant risk of the pumps failing. They also complained of frequent headaches, caused by poor air quality. The air underground was dirty and stale, contaminated because of the lack of an adequate ventilation system. There were lighting problems too. Illuminating the tunnels by candlelight was a constant challenge: lamps gave off only a very weak glow, and there were a number of accidents because the miners could not see what they were doing. Lastly, several of Brunel's miners walked off the job because they could not tolerate the excessive temperatures that developed in the cramped underground conditions.

\n

Despite all these setbacks, the tunnel finally emerged on the opposite river bank on 12 August 1841. Brunel's triumph, however, was only partial. The small payment per person made by the thousands of visitors who flocked to see the marvel hardly covered even a penny per foot of the tunnel's construction costs. Brunel had gone bankrupt long before the project was completed, and the government loan he had required to finish the work had to be paid back with interest. As a result, there was not enough funding to make the tunnel accessible to horse-drawn vehicles, as intended. Instead, the passageways were filled with souvenir sellers and entertainers. In the end, the tunnel was closed two years later and was used only at night before it was finally closed entirely and fell into dereliction for decades.

\n

It was only when the underground railway came to London in the 1880s that the Thames Tunnel achieved real usefulness. It was bought in 1869 by the East London Railway, which found it to be in such excellent condition that it was immediately pressed into service as a route for passenger trains heading east. The tunnel became, and remains, part of the London Underground network.

\n\n
\n \n \n
" + "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n

Tunnelling under the Thames

\n

The first tunnel ever to be built under a major river was the tunnel under London’s River Thames

\n

At the beginning of the 19th century, the port of London was the busiest in the world. Cargoes that had travelled thousands of miles and survived all the hazards of the sea were unloaded on the banks of the Thames, only for their owners to discover that the most frustrating portion of their journey lay ahead. Consignments intended for the southern parts of Britain had to be lifted onto horse carts, pulled through the docks and across London Bridge, built in the 12th century and as impractical as its early date implies. By 1820, London Bridge had become the centre of the world’s largest traffic jam.

\n

It was an intolerable situation, and it was clear that if private enterprise could build another crossing closer to the docks, there would be good money to be made in tolls paid by users. Another bridge was out of the question, as this would deny sailing ships access to the city centre, and ambitious men turned their thoughts to tunnelling beneath the Thames instead. This was not such an obvious idea as it might appear. Although increasing demand for coal had meant a great many tunnels had been dug in mines in Britain, working methods remained primitive. Tunnels were dug by men with simple tools, by candlelight. However, in 1807, a group of businessmen got themselves up as the Thames Archway Company. Their ambition was to tunnel below the Thames, but there was little to guide them as there had been no previous attempt to do this. Their chief engineer was Richard Trevithick, designer of the world’s first high-pressure steam engine. His men made progress at the beginning, but then things began to go disastrously wrong, with muddy soil pouring into the tunnel. Eventually the Thames Archway Company had had enough. Its funds were exhausted, Trevithick was sick from exposure to the river water, and its efforts had proved only that a passage under the river exceeded the limits of contemporary mining technology.

\n

At that time, the only machines used in mines were pumps. It took a man of genius to recognise that a different sort of machine was needed, a machine that could prevent the roof and walls of a tunnel from collapsing. This man was Marc Brunel, a Frenchman who had become one of the most prominent engineers in Britain. Not long after the failure of the Thames Archway Company, Brunel saw a rotten piece of wood lying on the river bank. Examining the wood through a magnifying glass, he observed it was infested with something that looked like a worm. Brunel realised that as it tunnelled through the wood, it would push chewed wood into its mouth and digest it, then excrete a hard substance that lined the new tunnel. Brunel realised that the worm’s digging technique could be adapted to produce a new way of tunnelling. His realisation led him to invent a device that has been used in one form or another in most major tunnels built since: the tunnelling shield. It consisted of a series of iron frames that could be pressed against the tunnel face and supported on a set of horizontal wooden planks that would prevent the face from collapsing. It was a complex machine and not easy to use, but it seemed that it would protect the miners. Brunel’s team examined earth samples taken from beneath the riverbed, and subsequently decided to dig the tunnel close to the muddy river bottom, where he could expect to find clay. This would be a more solid and safe substance to dig through than the sand that was found deeper down.

\n

Brunel began work on his tunnel in 1825, but the problems of such an operation soon became apparent. Although the shield itself worked well, water began to drip into the tunnel. This was more of an annoyance than a danger while the pump was working, but this machine proved unreliable and sometimes failed altogether, meaning that floods began to occur with increasing regularity. The miners often had to run for their lives. Even when Brunel’s men were able to work, they complained of frequent headaches, because the air underground was dirty and contaminated due to the lack of an adequate ventilation system. There were other problems too. Illuminating the tunnels was a constant challenge. Lamps gave off only a weak glow, and there were a number of accidents because the miners could not see what they were doing. Lastly, a number of Brunel’s miners walked off the job because they could not tolerate the excessive temperatures that developed in the cramped conditions underground.

\n

Despite all the setbacks, the tunnel finally emerged on the opposite river bank on August 12, 1841. Brunel’s triumph, however, was only partial. The small payment of a penny per person made by the thousands of visitors hardly paid the interest on the government loan he had required to complete the project. As a result, there was never enough funding to make it accessible to horse-drawn vehicles, as intended. Instead, the passageways were filled with souvenir-sellers by day and the homeless at night, before it was finally closed.

\n

It was only when the underground railway came to London in the 1860s that the Thames Tunnel achieved a measure of real usefulness. It was bought in 1869 by the East London Railway, who found it to be in such excellent condition that it was immediately pressed into service as a route for passenger trains heading east. The tunnel became, and remains, part of the London Underground network.

" } ] }, @@ -38,8 +38,8 @@ "q7", "q8" ], - "bodyHtml": "
\n

Do the following statements agree with the information in Reading Passage 1?

\n

In boxes 1–8 on your answer sheet, write:

\n
    \n
  • TRUE if the statement agrees with the information
  • \n
  • FALSE if the statement contradicts the information
  • \n
  • NOT GIVEN if there is no information about this
  • \n
\n \n
\n

1 In the early 19th century, the port of London was considered a safer destination than other ports.

\n
\n \n \n \n
\n
\n
\n

2 London Bridge provided quick access for cargo being sent to southern Britain.

\n
\n \n \n \n
\n
\n
\n

3 It was generally believed that a new river crossing would be profitable.

\n
\n \n \n \n
\n
\n
\n

4 Building a second bridge crossing was initially considered to be the best solution.

\n
\n \n \n \n
\n
\n
\n

5 It was believed that coal could be found under the River Thames.

\n
\n \n \n \n
\n
\n
\n

6 The Thames Archway Company was the first group to try tunnelling below the Thames.

\n
\n \n \n \n
\n
\n
\n

7 Some of Trevithick's men were injured during a mudslide at his tunnel.

\n
\n \n \n \n
\n
\n
\n

8 The Thames Archway Company ran out of money to finance the tunnel project.

\n
\n \n \n \n
\n
\n
", - "leadHtml": "

Questions 1–8

" + "bodyHtml": "
\n

Do the following statements agree with the information in Reading Passage 1?

\n

In boxes 1–7 on your answer sheet, write

\n
    \n
  • TRUE if the statement agrees with the information
  • \n
  • FALSE if the statement contradicts the information
  • \n
  • NOT GIVEN if there is no information on this
  • \n
\n \n
\n

1 In the early 19th century, the port of London was considered a safer destination than other ports.

\n
\n \n \n \n
\n
\n
\n

2 London Bridge provided quick access for cargo being sent to southern Britain.

\n
\n \n \n \n
\n
\n
\n

3 It was generally believed that a new river crossing would be profitable.

\n
\n \n \n \n
\n
\n
\n

4 Building a second bridge crossing was initially considered to be the best solution.

\n
\n \n \n \n
\n
\n
\n

5 It was believed that coal could be found under the River Thames.

\n
\n \n \n \n
\n
\n
\n

6 The Thames Archway Company were the first group to try tunnelling below the Thames.

\n
\n \n \n \n
\n
\n
\n

7 Some of Trevithick’s men were injured during a mudslide at his tunnel.

\n
\n \n \n \n
\n
\n
\n

8 The Thames Archway Company ran out of money to finance the tunnel project.

\n
\n \n \n \n
\n
\n
", + "leadHtml": "

Questions 1–7

" }, { "groupId": "group-2", @@ -51,7 +51,7 @@ "q12", "q13" ], - "bodyHtml": "
\n

Complete the notes below.

\n

Choose ONE WORD ONLY from the passage for each answer.

\n \n
\n

Marc Brunel's tunnel

\n

Preparing to build the tunnel

\n
    \n
  • Brunel noticed how a kind of 9 made its tunnels in wood.
  • \n
  • Brunel created a device called a tunnelling shield, to protect people working under the river.
  • \n
  • Brunel planned to build a shallow tunnel so the earth would have a higher content of 10 .
  • \n
\n

Problems faced by miners

\n
    \n
  • There were frequent floods caused by mechanical breakdowns.
  • \n
  • The miners suffered from 11 because of pollution in the tunnels.
  • \n
  • Lighting problems led to several 12 .
  • \n
  • Some workers quit because of the high temperatures in the tunnel.
  • \n
\n

After the tunnel was finished

\n
    \n
  • The tunnel was finally completed in 1841.
  • \n
  • Brunel did not have enough money to repay his debt to the 13 .
  • \n
  • The tunnel was abandoned until the 1880s.
  • \n
\n
\n
" + "bodyHtml": "
\n

Questions 9–13

\n

Complete the notes below.

\n

Choose ONE WORD ONLY from the passage for each answer.

\n

Write your answers in boxes 9–13 on your answer sheet.

\n \n
\n

Marc Brunel’s tunnel

\n

Preparing to build the tunnel

\n
    \n
  • Brunel noticed how a kind of 9 made its tunnels in wood.
  • \n
  • Brunel created a device called a ‘tunnelling shield’ to protect people working under the river.
  • \n
  • Brunel planned to build a shallow tunnel so the earth would have a higher content of 10 .
  • \n
\n

Problems faced by miners

\n
    \n
  • There were frequent floods caused by mechanical breakdowns.
  • \n
  • The miners suffered from 11 because of pollution in the tunnels.
  • \n
  • Lighting problems led to several 12 .
  • \n
  • Some workers quit because of the high temperatures in the tunnel.
  • \n
\n

After the tunnel was finished

\n
    \n
  • The tunnel was finally completed in 1841.
  • \n
  • Brunel did not have enough money to repay his debt to the 13 .
  • \n
  • The tunnel was abandoned until the 1860s.
  • \n
\n
\n
" } ], "answerKey": { @@ -77,8 +77,8 @@ "audit": { "matchStatus": "matched", "matchConfidence": 1, - "verifiedAt": "2026-03-08T16:24:30.706Z", - "notes": "signature:radio,text,textarea,dragdrop,table" + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the source DOCX passage, question text, and embedded answer-key images." }, "questionOrder": [ "q1", diff --git a/assets/generated/reading-exams/p2-medium-243.js b/assets/generated/reading-exams/p2-medium-243.js new file mode 100644 index 00000000..8b6c1c69 --- /dev/null +++ b/assets/generated/reading-exams/p2-medium-243.js @@ -0,0 +1,126 @@ +(function registerReadingExamData(global) { + 'use strict'; + if (!global.__READING_EXAM_DATA__ || typeof global.__READING_EXAM_DATA__.register !== "function") { + throw new Error("reading_exam_registry_missing"); + } + global.__READING_EXAM_DATA__.register("p2-medium-243", { + "schemaVersion": "ReadingExamSourceV1", + "examId": "p2-medium-243", + "meta": { + "title": "The internal body clock", + "category": "P2", + "frequency": "次高频", + "pdfFilename": "", + "legacyPath": "", + "legacyFilename": "", + "questionIntroHtml": "

Questions 14–26

" + }, + "passage": { + "blocks": [ + { + "blockId": "passage-main", + "kind": "html", + "html": "

READING PASSAGE 2

\n

You should spend about 20 minutes on Questions 14–26, which are based on Reading Passage 2 below.

\n

The internal body clock

\n

Each of our cells has an internal 'clock' which dictates our daily rhythms. But why?

\n

From buffalo to bacteria, oaks to algae, all life follows the same relentless 24-hour cycle, which is driven by the rising and setting of the Sun. Or is it? Over 250 years ago, a French scientist performed a simple experiment that blew apart the idea that all life on Earth does the bidding of the Sun. Intrigued by the way some flowers open and close their leaves each day, Jean-Jacques de Mairan put a heliotrope in a darkened room and observed the effect. He was expecting the plant, robbed of sunlight, to cease its daily routine. To his astonishment, its leaves continued to open and close as if in response to some invisible timekeeper.

\n

But what – and where – is this internal timekeeper? And how does it achieve such astonishing regularity? These are the mysteries at the forefront of chronobiology, the study of the effect of time in living organisms. The search for answers is about more than just tying up some scientific loose ends. Internal ‘clocks’ clearly cause a lot of hardship. When an individual’s ‘clock’ is knocked out of synchronization through having to work at night or go on long-haul flights, that person can be left utterly unable to think or act. Yet even when these ‘clocks’ work correctly, the natural rhythms of alertness they generate prove dangerous: traffic accident statistics show two deadly peaks, at 4 a.m. and again twelve hours later when people are at their least alert.

\n

During the 1970s, best-selling books began to emerge claiming such phenomena were manifestations of so-called biorhythms, a set of three cycles governing physical, emotional and intellectual traits. Said to start from the moment of birth and repeat every 23, 28 and 33 days respectively, they were supposed to result in ‘critical days’ when one or more cycles led to sub-optimal performance, with unfortunate consequences.

\n

Scientists have since dismissed biorhythms as pseudoscience, insisting the existence of the three cycles has no basis in fact. In 1998, Dr Terence Hines of Pace University, New York State published the most comprehensive study of the claims made for biorhythms, reviewing the results of over 130 investigations. He found that three-quarters of them failed to provide any support for biorhythms. Most of the remainder contained blunders ranging from faulty mathematics to basic errors in statistics, while the handful of positive studies were explicable as flukes.

\n

But there is no doubting the existence of 24-hour biological cycles – or, rather, ones roughly 24 hours long. Following de Mairan’s pioneering work, other researchers found that when deprived of the cues provided by sunlight, organisms settle down to ‘free-running’ cycles that are close to, but rarely exactly, 24 hours long. In the case of humans, the cycle is typically around 24.5 hours long. This is the so-called ‘circadian’ cycle (from the Latin meaning ‘about a day’), the length of which is generated by the mysterious internal ‘clock’.

\n

During the late 1960s, chronobiologists believed they had found the ‘clock’, in the form of the suprachiasmatic nucleus (SCN), a collection of nerves in a region of the brain known as the hypothalamus. Linked to photosensitive cells in the eye, the SCN senses daylight and triggers the release of hormones like melatonin, which keep body functions in synchronization with the time of day. For a few years the SCN was regarded as the ultimate pacemaker – at least in higher organisms such as ourselves. But in 1971, scientists at the California Institute of Technology working with fruit flies found evidence for something truly amazing. Fruit flies appeared to have genes affecting the daily rhythm of their behavior – suggesting that there are ‘clocks’ inside each of their cells. Further evidence for this emerged in 1995, when researchers at Massachusetts General Hospital isolated nerve cells from the SCN, and found they kept up a circadian cycle without help from daylight. Finally in 1997, scientists at Northwestern University, Illinois, found a gene that regulates the daily rhythms of cellular activity in mammals.

\n

The details of how this gene, known as CLOCK, actually works are still being investigated, but they could culminate in better ways of coping with shift-work and jet-lag. Professor Takahashi, who led the research, says that now that we know that circadian clocks exist throughout our bodies, we will need new strategies and therapeutics to reset all of our cells. But how do all the biochemical ‘clocks’ inside their cells stay in synchronization and why did organisms bother to acquire a link with sunlight? So far, no-one knows, though there are several theories. For example, sunlight might be useful in keeping the myriad cellular ‘clocks’ in lockstep.

\n

In the search for answers, researchers are studying the behavior of a microbe called cyanobacterium. In 2005, a team at Nagoya University, Japan, showed that chemical reactions between three proteins produced by this bacterium ebbed and flowed on a 24-hour cycle. Cyanobacterium is the oldest form of life on Earth, so this suggests that cellular ‘clocks’ have existed for over three billion years. All subsequent organisms have followed suit, says Professor Johnson of Vanderbilt University, Tennessee. ‘Bacteria, fungi, plants and animals all appear to have evolved clock systems independently from each other.’ Quite why is still unknown. But one thing is clear: the daily routine of life is certainly not a modern invention.

" + } + ] + }, + "questionGroups": [ + { + "groupId": "group-1", + "kind": "matching", + "questionIds": [ + "q1", + "q2", + "q3", + "q4", + "q5" + ], + "bodyHtml": "
\n

Questions 14–18

\n

Reading Passage 2 has eight paragraphs, A–H.

\n

Which paragraph contains the following information?

\n

Write the correct letter, A–H, in boxes 14–18 on your answer sheet.

\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ABCDEFGH
14 support for an earlier finding from experiments on insects
15 criticism of the way some research data was analysed
16 reference to a basic test on a plant
17a statement that the length of natural cycles varies slightly between living things
18 reference to a theory that became popular with the public
\n
\n
", + "allowOptionReuse": true + }, + { + "groupId": "group-2", + "kind": "summary_completion", + "questionIds": [ + "q6", + "q7", + "q8", + "q9" + ], + "bodyHtml": "
\n

Questions 19–22

\n

Complete the summary below.

\n

Choose NO MORE THAN TWO WORDS from the passage for each answer.

\n

Write your answers in boxes 19–22 on your answer sheet.

\n

An overview of research into the existence of the internal ‘clock’

\n

Towards the end of the 1960s, research into the SCN, which is located in the hypothalamus, led chronobiologists to believe they had found the internal ‘clock’ at last. Their theory was based on the fact that certain hormones that regulate the timing of physical functions are controlled by the SCN. The SCN is able to perceive 19 as a result of connections to the eye.

\n

Several years later, research involving 20 showed that the timing of their normal actions was regulated by certain 21 , or ‘clocks’, in their cells. Thus the internal ‘clock’ is not an individual mechanism, as previously thought, but exists within each cell. Other scientists went on to conduct experiments in the dark that proved that a 22 was maintained by individual SCN cells.

\n
" + }, + { + "groupId": "group-3", + "kind": "multi_choice", + "questionIds": [ + "q10", + "q11" + ], + "bodyHtml": "
\n

Questions 23 and 24

\n

Choose TWO letters, A–E.

\n

Write the correct letters in boxes 23 and 24 on your answer sheet.

\n

Which TWO of the following problems are stated as being linked with the internal ‘clock’?

\n
\n\n\n\n\n\n
\n
", + }, + { + "groupId": "group-4", + "kind": "multi_choice", + "questionIds": [ + "q12", + "q13" + ], + "bodyHtml": "
\n

Questions 25 and 26

\n

Choose TWO letters, A–E.

\n

Write the correct letters in boxes 25 and 26 on your answer sheet.

\n

Which TWO of the following issues relating to internal ‘clocks’ remain to be solved, according to information in the passage?

\n
\n\n\n\n\n\n
\n
", + } + ], + "answerKey": { + "q1": "F", + "q2": "D", + "q3": "A", + "q4": "E", + "q5": "C", + "q6": "daylight", + "q7": "fruit flies", + "q8": "genes", + "q9": "circadian cycle", + "q10": "B", + "q11": "C", + "q12": "C", + "q13": "D" + }, + "sourceRefs": { + "pdf": "" + }, + "audit": { + "matchStatus": "matched", + "matchConfidence": 1, + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the embedded DOCX answer-key images and source question text." + }, + "questionOrder": [ + "q1", + "q2", + "q3", + "q4", + "q5", + "q6", + "q7", + "q8", + "q9", + "q10", + "q11", + "q12", + "q13" + ], + "questionDisplayMap": { + "q1": "14", + "q2": "15", + "q3": "16", + "q4": "17", + "q5": "18", + "q6": "19", + "q7": "20", + "q8": "21", + "q9": "22", + "q10": "23", + "q11": "24", + "q12": "25", + "q13": "26" + } +}); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/assets/generated/reading-exams/p3-high-173.js b/assets/generated/reading-exams/p3-high-173.js index e06132d8..2567111d 100644 --- a/assets/generated/reading-exams/p3-high-173.js +++ b/assets/generated/reading-exams/p3-high-173.js @@ -7,20 +7,20 @@ "schemaVersion": "ReadingExamSourceV1", "examId": "p3-high-173", "meta": { - "title": "Robert Louis Stevenson 苏格兰作家", + "title": "Robert Louis Stevenson", "category": "P3", "frequency": "high", "pdfFilename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.pdf", "legacyPath": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", "legacyFilename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", - "questionIntroHtml": "

Questions

" + "questionIntroHtml": "

Questions 27–31

" }, "passage": { "blocks": [ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 3

\n

You should spend about 20 minutes on Questions 27–40, which are based on Reading Passage 3 below.

\n \n

Robert Louis Stevenson

\n

The writer of some of the best-known stories in the English language, including Treasure Island and The Strange Case of Dr. Jekyll and Mr. Hyde

\n \n

It is more than 100 years since the death of the Scottish writer Robert Louis Stevenson on the South Pacific island of Samoa. And it seems that time has not been kind to Stevenson's memory. Immediately after his death, his family and friends set to work to fashion the legend of Robert Louis Stevenson, or R.L.S. as he became known—one of the few writers familiar from his initials alone. Subsequent works of biography then turned him into a writer of almost religious importance. One example was critic Balfour, who in 1901 portrayed Stevenson's family as ministering angels to the dying genius during his final illness. Similarly, the biographer Crouch absurdly overstated Stevenson's significance by placing him in the same company as those most revered names in English literature, Shakespeare and Keats. The reaction to this nonsense was a number of highly critical assessments of Stevenson's legacy in the 1920s.

\n

Normally, the critical pendulum can be relied on to swing back again, but there are several aspects of Stevenson's work that have until recently acted against a more balanced appraisal. First is the allegation that Stevenson was a mere master of linguistic fireworks who lacked moral depth. Some critics accused him of being a literary charlatan, juggling words very prettily to strike effects that overawed an ignorant public and served to distract from the inadequacy of his ideas.

\n

Then there has been a prejudice against the adventure story as the proper medium for deep moral seriousness, a prejudice which is still extremely influential today. It seems that we can accept that an adventure film can successfully express profound moral truths, but we reject the same idea for a book. The absurdity of this becomes apparent when we think of writers like Joseph Conrad and Graham Greene, but it is no use pretending that this bias against adventure stories is not part of our high culture. A further problem is that Stevenson has often not found favour in the land of his birth because his conservatism so often collides with the strong radical tradition in Scotland. His many escapist stories and preference for living abroad have led to accusations that he camouflaged Scotland's real problems. Lastly, the high adventure of Stevenson's own lifestyle has sometimes obscured his output. His globe-trotting, and above all the final phase of his life in Samoa, tended to make his own life a greater story than any he could devise. This was precisely what his friends feared would happen towards the end of his short life: his art might be overwhelmed by the drama of life in Samoa.

\n

One consequence of this has been that Stevenson's influence on other writers has too often been neglected. The writer and poet Oscar Wilde was deeply influenced by Stevenson, even though he declared that Stevenson would have produced better work if he had lived in London rather than Samoa. Stevenson tends to stick in the throat even of those writers who would like to spit him out, such as Shaw, who claimed to have learned from him that the romantic hero is always mocked by reality. Likewise, the writer Galsworthy, who was a determined critic, later changed his mind and said that the superiority of Stevenson over the novelist Hardy was that Stevenson was all life and Hardy, all death. The influence on the novelist Chesterton would also repay detailed study, for it was through him that Stevenson has managed to cross the ages, emerging as an influence on the modernist movement and our own contemporary Latin American school of magical realism.

\n

When making an assessment of his life and work, one question must inevitably be asked: was Robert Louis Stevenson Scotland's greatest writer of English prose? For most commentators this honour falls to Sir Walter Scott, author of Ivanhoe among many other classic novels, and it is true that in terms of craftsmanship, precision and the ability to minutely regulate language to create the desired effect, Scott takes the prize. However, this is not the same thing at all as inherent talent: by way of comparison one may take the example of the two great Russian composers Shostakovich and Prokofiev, of whom the former had learned more precise skills of execution but the latter's intrinsic genius was greater, and so it seems to be with Scott and Stevenson. Admittedly, Scott's detailed style does permit his stories to explore levels of tragedy that are beyond Stevenson's reach, but in this regard they have the musty smell of the museum, somehow artificial and removed from modern-day reality. On the other hand, Stevenson's skill with plotting and narrative give his books a timeless quality, so that they still live today. And Stevenson was also the shrewder judge of behaviour and psychology. For example, his compelling description of a man with a split personality in The Strange Case of Dr. Jekyll and Mr. Hyde has proved so accessible and accurate that the expression “Jekyll and Hyde” has entered common English usage. Even if we do not see a revival of critical interest in this great Scottish writer, it is to be hoped that readers go back to Robert Louis Stevenson's magnificent stories and reassess this neglected genius.

\n\n
\n \n \n
" + "html": "

READING PASSAGE 3

\n

You should spend about 20 minutes on Questions 27-40, which are based on Reading Passage 3 below.

\n

Robert Louis Stevenson

\n

The writer of some of the best-known stories in the English language, including Treasure Island and The Strange Case of Dr. Jekyll and Mr. Hyde

\n

It is more than 100 years since the death of the Scottish writer Robert Louis Stevenson on the South Pacific island of Samoa, and it seems that time has not been kind to Stevenson’s memory. Immediately after his death, his family and friends set to work to fashion the legend of Robert Louis Stevenson, or R. L. S., as he became known, one of the few writers familiar from his initials alone. Subsequent works of biography then turned him into a writer of almost religious importance. One example was literary critic Balfour, who in 1901 portrayed Stevenson’s family as ministering angels to the dying genius during his final illness. Similarly, the biographer Crouch absurdly overstated Stevenson’s significance by placing him in the same company as those most revered names in English literature: Shakespeare and Keats. The reaction to this nonsense was a number of highly critical assessments of Stevenson’s legacy in the 1920s.

\n

Normally, the critical pendulum can be relied on to swing back again, but there are several aspects of Stevenson’s work that have, until recently, acted against a more balanced appraisal. First is the allegation that Stevenson was a mere master of linguistic fireworks, who lacked moral depth. Some critics accused him of being a literary charlatan, of juggling words very prettily to strike effects which overawed an ignorant public, and served to distract from the inadequacy of his ideas.

\n

Then there has long been a prejudice against the adventure story as the proper medium for deep moral seriousness, a prejudice which is still extremely influential today. It seems that we can accept that an adventure film can successfully express profound moral truths, but we reject the same idea for a book. The absurdity of this becomes apparent when we think of writers like Joseph Conrad and Graham Greene, but it is no use pretending that this bias against adventure stories is not part of our high culture. A further problem is that Stevenson has often not found favour in the land of his birth because his conservatism so often collides with the strong radical tradition in Scotland. His many escapist stories and preference for living abroad have led to accusations that he camouflaged Scotland’s problems. Lastly, the high adventure of Stevenson’s own lifestyle has sometimes obscured his output, his life a greater story than any he could devise. This was precisely what his friends feared would happen towards the end of his short life: his art might be overwhelmed by the drama of life in Samoa.

\n

One consequence of this has been that Stevenson’s influence on other writers has too often been neglected. The writer and poet Oscar Wilde was deeply influenced by Stevenson, even though he declared that Stevenson would have produced better work if he had lived in London rather than Samoa. Stevenson tends to stick in the throat even of those writers who would like to spit him out, such as Shaw, who claimed to have learned from him that the romantic hero is always mocked by reality. Likewise, the writer Galsworthy, who began as a determined critic, later changed his mind and said that the superiority of Stevenson over the novelist Hardy was that Stevenson was all life and Hardy all death. The influence on the novelist Chesterton would also repay detailed study, for it was through him that Stevenson has managed to cross the ages, emerging as an influence on the modernist movement and our own contemporary Latin American school of “magical realism”.

\n

When making an assessment of his life and work one question must inevitably be asked: was Robert Louis Stevenson Scotland’s greatest writer of English prose? For most commentators this honour falls to Sir Walter Scott, author of Ivanhoe among many other classic novels, and it is true that in terms of craftsmanship, precision and the ability to minutely regulate language to create the desired effect, Scott takes the prize. However, this is not the same thing at all as inherent talent; by way of comparison one may take the example of the two great Russian composers Shostakovich and Prokofiev, of whom the former had learned more precise skills of execution but the latter’s intrinsic genius was greater, and so it seems to be with Scott and Stevenson. Admittedly, Scott’s detailed style does permit his stories to explore levels of tragedy that are beyond Stevenson’s reach, but in this regard they have the musty smell of the museum, somehow artificial and removed from modern day reality. On the other hand, Stevenson’s skill with plotting and narrative gives his books a timeless quality, so that they still live today, and Stevenson was also the shrewder judge of behaviour and psychology. For example, his compelling descriptions of a man with a split personality in The Strange Case of Dr. Jekyll and Mr. Hyde have proved so accessible and accurate that the expression “Jekyll and Hyde” has entered common English usage. Even if we do not see a revival of critical interest in this great Scottish writer, it is to be hoped that readers go back to Robert Louis Stevenson’s magnificent stories and reassess this neglected genius.

" } ] }, @@ -35,8 +35,8 @@ "q4", "q5" ], - "bodyHtml": "
\n

Questions 27–31

\n

Choose the correct letter, A, B, C or D.

\n
\n

27 In the opinion of the writer, the biographers Balfour and Crouch

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n
\n

28 What is the writer's main point about Stevenson in the second paragraph?

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n
\n

29 According to the writer, the adventure story

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n
\n

30 What point does the writer make about Stevenson and Scotland?

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n
\n

31 According to the writer, Stevenson's own lifestyle

\n
    \n
  • \n
  • \n
  • \n
  • \n
\n
\n
", - "leadHtml": "

Questions

" + "bodyHtml": "
\n

Questions 27–31

\n

Choose the correct letter A, B, C or D.

\n

Write the correct letter in boxes 27–31 on your answer sheet.

\n
\n

27\tIn the opinion of the writer, the biographers Balfour and Crouch

\n
\n\n\n\n\n
\n
\n
\n

28\tWhat is the writer’s main point about Stevenson in the second paragraph?

\n
\n\n\n\n\n
\n
\n
\n

29\tAccording to the writer, the adventure story

\n
\n\n\n\n\n
\n
\n
\n

30\tWhat point does the writer make about Stevenson and Scotland?

\n
\n\n\n\n\n
\n
\n
\n

31\tAccording to the writer, Stevenson’s own lifestyle

\n
\n\n\n\n\n
\n
\n
", + "leadHtml": "

Questions 27–31

" }, { "groupId": "group-2", @@ -47,7 +47,7 @@ "q8", "q9" ], - "bodyHtml": "
\n

Questions 32–35

\n

Do the following statements agree with the claims of the writer in Reading Passage 3?

\n

In boxes 32–35 on your answer sheet, write:

\n
    \n
  • YES if the statement agrees with the claims of the writer
  • \n
  • NO if the statement contradicts the claims of the writer
  • \n
  • NOT GIVEN if it is impossible to say what the writer thinks about this
  • \n
\n
\n

32 Although Oscar Wilde admired Robert Louis Stevenson very much, he believed Stevenson could have written greater works.

\n
\n \n \n \n
\n
\n
\n

33 Robert Louis Stevenson encouraged Oscar Wilde to start writing in the first place.

\n
\n \n \n \n
\n
\n
\n

34 Galsworthy respected Hardy's works more than Stevenson's.

\n
\n \n \n \n
\n
\n
\n

35 There is a need to study in detail Stevenson's influence on Chesterton.

\n
\n \n \n \n
\n
\n
" + "bodyHtml": "
\n

Questions 32–35

\n

Do the following statements agree with the views of the writer in Reading Passage 3?

\n

In boxes 32–35 on your answer sheet write

\n

YES\tif the statement agrees with the views of the writer
NO\tif the statement contradicts the views of the writer
NOT GIVEN\tif it is impossible to say what the writer thinks about this

\n

32\tAlthough Oscar Wilde admired Stevenson’s work, he believed Stevenson could have written something better.

\n

33\tStevenson encouraged Oscar Wilde to start writing.

\n

34\tGalsworthy had greater respect for Hardy than Stevenson.

\n

35\tMore research is needed regarding Stevenson’s influence on Chesterton.

\n
", }, { "groupId": "group-3", @@ -59,7 +59,7 @@ "q13", "q14" ], - "bodyHtml": "
\n

Questions 36–40

\n

Complete the summary using the list of words, A–I, below.

\n
\n
Sir Walter Scott and Robert Louis Stevenson
\n

A lot of people believe that Sir Walter Scott and Robert Louis Stevenson are the most influential writers in the history of Scotland, but Sir Walter Scott is more proficient in, while Stevenson has better. Scott's books illustrate, especially in terms of tragedy, but many readers prefer Stevenson's. What's more, Stevenson's understanding of gave his works a unique expression of the Scottish people.

\n
\n
\n A natural ability\n B romance\n C colorful language\n D critical acclaim\n E humor\n F technical control\n G storytelling\n H depth\n I human nature\n
\n
" + "bodyHtml": "
\n

Questions 36–40

\n

Complete the summary using the list of words and phrases, A–I below.

\n

Write the correct letter, A–I, in boxes 36–40 on your answer sheet.

\n
\n
Robert Louis Stevenson and Sir Walter Scott
\n

Opinions differ as to whether Robert Louis Stevenson or Sir Walter Scott should be considered Scotland’s best writer. Scott had greater , but Stevenson had more , and the same distinction can be made between the two composers Shostakovich and Prokofiev. It is true that Scott’s books showed more when it came to tragedy, though in an old-fashioned way, while Stevenson’s books are still popular because of his . And Stevenson’s understanding of has resulted in the widespread use of an expression from one of his books.

\n
\n
\nA\tnatural ability\nB\tcritical acclaim\nC\thumour\nD\tromance\nE\tcolourful language\nF\ttechnical control\nG\tstorytelling\nH\tdepth\nI\thuman nature\n
\n
", } ], "answerKey": { @@ -86,8 +86,8 @@ "audit": { "matchStatus": "matched", "matchConfidence": 1, - "verifiedAt": "2026-03-08T16:24:30.800Z", - "notes": "signature:radio,text,textarea,dragdrop,table" + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the embedded DOCX answer-key images and source question text." }, "questionOrder": [ "q1", diff --git a/assets/generated/reading-exams/p3-medium-244.js b/assets/generated/reading-exams/p3-medium-244.js new file mode 100644 index 00000000..9d3a9b25 --- /dev/null +++ b/assets/generated/reading-exams/p3-medium-244.js @@ -0,0 +1,124 @@ +(function registerReadingExamData(global) { + 'use strict'; + if (!global.__READING_EXAM_DATA__ || typeof global.__READING_EXAM_DATA__.register !== "function") { + throw new Error("reading_exam_registry_missing"); + } + global.__READING_EXAM_DATA__.register("p3-medium-244", { + "schemaVersion": "ReadingExamSourceV1", + "examId": "p3-medium-244", + "meta": { + "title": "Look who was talking", + "category": "P3", + "frequency": "次高频", + "pdfFilename": "", + "legacyPath": "", + "legacyFilename": "", + "questionIntroHtml": "

Questions 27–32

" + }, + "passage": { + "blocks": [ + { + "blockId": "passage-main", + "kind": "html", + "html": "

READING PASSAGE 3

\n

You should spend about 20 minutes on Questions 27-40, which are based on Reading Passage 3 below.

\n

Look who was talking

\n

Stephen Oppenheimer explores the origins of human speech

\n

When did we start talking to each other and how long did it take us to become so good at it? One view of language development, held by linguists such as Noam Chomsky and anthropologists such as Richard Klein, is that language, specifically the spoken word, appeared suddenly among modern humans a mere 35,000 to 50,000 years ago and that the ability to speak words and use syntax was recently genetically hard-wired into our brains in a kind of language organ. This view of language is associated with the old idea that logical thought is dependent on words, a concept originating with Plato and much in vogue in the 19th century – that is, animals do not speak because they do not think. However, the abstract thought demonstrated in 20th-century experiments with chimpanzees and bonobos put this theory in doubt.

\n

An alternative to the Chomskian theory is that language developed as a series of inventions. This was first suggested by the 18th-century philosopher Etienne Bonnot de Condillac. He argued that spoken language had developed out of gesture language (langage d’action) and that both were inventions arising initially from the simple association between action and object. The theory sees gesture language as arising originally among apes as sounds accompanying gestures, with these sounds gradually becoming coded into ‘words’ as the new skill drove its own evolution. Subsequently, coded words developed into deliberate, complex communication. Evolutionary pressures promoted the development of an anatomy geared to speech – the larynx, vocal muscles and a specific part of the brain immediately next to the part responsible for gestures.

\n

The view that spoken language was ultimately a cultural invention like tool-making, which then drove the biological evolution of the brain and vocal apparatus, seems obvious when you think of the development of different languages. The unique features of a language such as French clearly do not result from any physiological aspect of being French but are the cultural possessions of the French-speaking community. Each language evolves from one generation to the next, constantly adapting itself to cope with the learning biases of each new set of young, immature minds.

\n

Those anthropologists and fossil experts who accept that speech started early still tend to think of language evolution as a gradual 2-million-year process, with our own modern human species (Homo sapiens) way out at the top. A major reason for this is the perception that brain growth among humans was gradual over a similar period. Several recent changes in the fossil evidence, though, bring this into doubt.

\n

The first of these is a re-dating of soil layers from the famous Olduvai Gorge in East Africa, where many key fossil remains have been found. A number of big-brained human species appear to be much older than previously thought, with several specimens dating from over a million years ago. When brain sizes for all available skulls are plotted against time using the revised dates, the result is startling: the bulk of increases in brain size was over by around 1.2 million years ago, with some African human species having brain volumes easily within the modern human range by that time.

\n

So, we have the paradox that over the period when our brain was growing most rapidly, our material cultural development, as measured by stone tools, advanced only marginally; then, over a million years later, when the development of anatomically modern humans finally started to accelerate, artistically and technologically, our brains were actually getting smaller.

\n

The additional piece of evidence that makes this paradox all the more significant is that brain size did not just leap between human species in a direct line of ascent towards ourselves. Over the period from 2.5 to 1.5 million years ago, brains were growing more rapidly than at any time since, within all the different human species. The logical conclusion is that there must have been a unique new behaviour driving brain growth, shared between all species of humans.

\n

So, what was driving rapid brain growth right at the beginning, 2.5 million years ago? The answer may have been staring us in the face. Namely, that not only early humans but their ancestors had started the trend in the very useful skill of verbal communication. Around 2.5 million years ago, the weather took a decided turn for the worse, becoming more variable and colder and drier. The search for food became more taxing, and there would have been a real need to communicate more effectively and cope with the worsening environment in a co-operative way. The near maximum in brain size achieved by 1.2 million years ago indicates that those early ancestors could already have been talking perfectly well. Our brain, which had developed to manipulate and organise complex symbolic aspects of speech internally, could now be turned to a variety of other tasks.

\n

So what happened in the million-year gap after that? Why did we take so long to get to the moon? Cultural evolution aided by communication and teaching is a cumulative interactive process. If each new generation invented just one new skill or idea and passed it on with the rest to their children and cousins, you could predict exactly the same curve of cultural advance as we see from the archaeological and historical record – first very slow, then faster and faster.

" + } + ] + }, + "questionGroups": [ + { + "groupId": "group-1", + "kind": "matching", + "questionIds": [ + "q1", + "q2", + "q3", + "q4", + "q5", + "q6" + ], + "bodyHtml": "
\n

Questions 27–32

\n

Look at the following statements (Questions 27–32) and the list of people below.

\n

Match each statement with the correct person, A, B, or C.

\n

Write the correct letter, A, B, or C, in boxes 27–32 on your answer sheet.

\n

NB You may use any letter more than once.

\n

List of People

\n
  • A Chomsky
  • B Condillac
  • C neither Chomsky nor Condillac
\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ABC
27 The development of human speech can be traced back to apes.
28 Animal research is essential for understanding the development of human speech.
29 Language emerged relatively late in human evolution.
30 Non-verbal language was essential in the development of verbal language.
31 The development of different languages is related more to environmental than biological factors.
32 The ability to think rationally is linked to the ability to speak.
\n
\n
", + "allowOptionReuse": true + }, + { + "groupId": "group-2", + "kind": "yes_no_not_given", + "questionIds": [ + "q7", + "q8", + "q9", + "q10", + "q11" + ], + "bodyHtml": "
\n

Questions 33–37

\n

Do the following statements agree with the claims of the writer in Reading Passage 3?

\n

In boxes 33–37 on your answer sheet, write

\n

YES if the statement agrees with the claims of the writer
NO if the statement contradicts the claims of the writer
NOT GIVEN if it is impossible to say what the writer thinks about this

\n

33 Anthropologists now agree on the point in time when speech began.

\n

34 The rate of human technological development in ancient times was directly related to brain size.

\n

35 The period when the brain was growing most quickly has now been identified.

\n

36 The development of agriculture influenced language development.

\n

37 Cultural development has been seen to follow a particular pattern throughout human history.

\n
" + }, + { + "groupId": "group-3", + "kind": "matching", + "questionIds": [ + "q12", + "q13", + "q14" + ], + "bodyHtml": "
\n

Questions 38–40

\n

Look at the following statements (Questions 38–40) and the list of dates below.

\n

Match each statement with the correct date, A, B, C or D.

\n

Write the correct letter, A, B, C or D, in boxes 38–40 on your answer sheet.

\n

NB You may use any letter more than once.

\n

List of Dates

\n
  • A 2.5 million years ago
  • B 1.5 million years ago
  • C 1.2 million years ago
  • D 0.5 million years ago
\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ABCD
38 Revised fossil evidence indicates most brain growth was complete.
39 The writer suggests that language development was complete.
40 An external change may have influenced language development.
\n
\n
", + "allowOptionReuse": true + } + ], + "answerKey": { + "q1": "B", + "q2": "C", + "q3": "A", + "q4": "B", + "q5": "C", + "q6": "A", + "q7": "NO", + "q8": "NO", + "q9": "YES", + "q10": "NOT GIVEN", + "q11": "YES", + "q12": "C", + "q13": "C", + "q14": "A" + }, + "sourceRefs": { + "pdf": "" + }, + "audit": { + "matchStatus": "matched", + "matchConfidence": 1, + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the embedded DOCX answer-key images and source question text." + }, + "questionOrder": [ + "q1", + "q2", + "q3", + "q4", + "q5", + "q6", + "q7", + "q8", + "q9", + "q10", + "q11", + "q12", + "q13", + "q14" + ], + "questionDisplayMap": { + "q1": "27", + "q2": "28", + "q3": "29", + "q4": "30", + "q5": "31", + "q6": "32", + "q7": "33", + "q8": "34", + "q9": "35", + "q10": "36", + "q11": "37", + "q12": "38", + "q13": "39", + "q14": "40" + } +}); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/bundles/core-foundation.bundle.js b/js/bundles/core-foundation.bundle.js index a18dbc22..9ecf794b 100644 --- a/js/bundles/core-foundation.bundle.js +++ b/js/bundles/core-foundation.bundle.js @@ -9934,3486 +9934,3517 @@ storageManager.ready "listening": "ListeningPractice/" }; const manifest = { - "p1-high-01": { - "examId": "p1-high-01", - "dataKey": "p1-high-01", - "script": "./p1-high-01.js", - "title": "A Brief History of Tea 茶叶简史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/", - "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-02": { - "examId": "p1-low-02", - "dataKey": "p1-low-02", - "script": "./p1-low-02.js", - "title": "Maori Fish Hooks 毛利鱼钩", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/", - "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-03": { - "examId": "p3-high-03", - "dataKey": "p3-high-03", - "script": "./p3-high-03.js", - "title": "What makes a musical expert_ 音乐天赋", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", - "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-04": { - "examId": "p3-high-04", - "dataKey": "p3-high-04", - "script": "./p3-high-04.js", - "title": "Yawning 打呵欠", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", - "filename": "101. P3 - Yawning 打呵欠【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-05": { - "examId": "p1-high-05", - "dataKey": "p1-high-05", - "script": "./p1-high-05.js", - "title": "Katherine Mansfield 新西兰作家", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/", - "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-06": { - "examId": "p2-low-06", - "dataKey": "p2-low-06", - "script": "./p2-low-06.js", - "title": "Biomimicry 仿生学", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", - "filename": "103. P2 - Biomimicry 仿生学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-07": { - "examId": "p3-low-07", - "dataKey": "p3-low-07", - "script": "./p3-low-07.js", - "title": "Star Performers 明星员工", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", - "filename": "104. P3 - Star Performers 明星员工.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-08": { - "examId": "p2-low-08", - "dataKey": "p2-low-08", - "script": "./p2-low-08.js", - "title": "How the Petri dish supports scientific advances 培养皿", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/", - "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-09": { - "examId": "p2-high-09", - "dataKey": "p2-high-09", - "script": "./p2-high-09.js", - "title": "Early Approaches to Organisational Design 组织设计", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", - "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-10": { - "examId": "p2-medium-10", - "dataKey": "p2-medium-10", - "script": "./p2-medium-10.js", - "title": "A study of western celebrity 西方名人", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/", - "filename": "107. P2 - A study of western celebrity 西方名人【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-11": { - "examId": "p1-low-11", - "dataKey": "p1-low-11", - "script": "./p1-low-11.js", - "title": "Bovids 牛科动物", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/", - "filename": "108. P1 - Bovids 牛科动物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-12": { - "examId": "p3-low-12", - "dataKey": "p3-low-12", - "script": "./p3-low-12.js", - "title": "Humanities and the health professional 人文医学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/", - "filename": "109. P3 - Humanities and the health professional 人文医学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-13": { - "examId": "p1-low-13", - "dataKey": "p1-low-13", - "script": "./p1-low-13.js", - "title": "Report on a university drama project 大学戏剧项目报告", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", - "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-14": { - "examId": "p2-high-14", - "dataKey": "p2-high-14", - "script": "./p2-high-14.js", - "title": "Should space be explored by robots or by humans 人机太空探索", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", - "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-15": { - "examId": "p3-high-15", - "dataKey": "p3-high-15", - "script": "./p3-high-15.js", - "title": "Whale Culture 鲸鱼文化", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", - "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-16": { - "examId": "p2-high-16", - "dataKey": "p2-high-16", - "script": "./p2-high-16.js", - "title": "The Importance of Law 法律的意义", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/", - "filename": "112. P2 - The Importance of Law 法律的意义【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-17": { - "examId": "p2-high-17", - "dataKey": "p2-high-17", - "script": "./p2-high-17.js", - "title": "Herbal Medicines 新西兰草药", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/", - "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-18": { - "examId": "p3-medium-18", - "dataKey": "p3-medium-18", - "script": "./p3-medium-18.js", - "title": "Unlocking the mystery of dreams 梦的解析", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/", - "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-19": { - "examId": "p2-high-19", - "dataKey": "p2-high-19", - "script": "./p2-high-19.js", - "title": "Mind Music 脑海中的音乐(心灵音乐)", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", - "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-20": { - "examId": "p1-medium-20", - "dataKey": "p1-medium-20", - "script": "./p1-medium-20.js", - "title": "The Development of Plastics 塑料的发展史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/", - "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-21": { - "examId": "p2-high-21", - "dataKey": "p2-high-21", - "script": "./p2-high-21.js", - "title": "Stress Less 工作压力", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", - "filename": "117. P2 - Stress Less 工作压力【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-22": { - "examId": "p3-medium-22", - "dataKey": "p3-medium-22", - "script": "./p3-medium-22.js", - "title": "Neanderthal Technology 尼安德特人的生存技艺", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", - "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-23": { - "examId": "p2-high-23", - "dataKey": "p2-high-23", - "script": "./p2-high-23.js", - "title": "The Constant Evolution of the Humble Tomato 番茄的演化", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", - "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-24": { - "examId": "p1-high-24", - "dataKey": "p1-high-24", - "script": "./p1-high-24.js", - "title": "Rubber 橡胶", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/", - "filename": "12. P1 - Rubber 橡胶【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-25": { - "examId": "p2-high-25", - "dataKey": "p2-high-25", - "script": "./p2-high-25.js", - "title": "Will Eating Less Make You Live Longer 节食与长寿", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", - "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-27": { - "examId": "p1-high-27", - "dataKey": "p1-high-27", - "script": "./p1-high-27.js", - "title": "Footprints in the Mud 恐龙脚印", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", - "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-28": { - "examId": "p3-low-28", - "dataKey": "p3-low-28", - "script": "./p3-low-28.js", - "title": "Images and Places 风景与印记", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/", - "filename": "123. P3 - Images and Places 风景与印记.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-29": { - "examId": "p1-medium-29", - "dataKey": "p1-medium-29", - "script": "./p1-medium-29.js", - "title": "The extinction of the cave bear 洞熊的灭绝", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/", - "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-30": { - "examId": "p1-low-30", - "dataKey": "p1-low-30", - "script": "./p1-low-30.js", - "title": "Investing in the Future 投资未来", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", - "filename": "125. P1 - Investing in the Future 投资未来.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-31": { - "examId": "p1-high-31", - "dataKey": "p1-high-31", - "script": "./p1-high-31.js", - "title": "Dolls through the ages 玩偶的变迁史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/", - "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-32": { - "examId": "p3-high-32", - "dataKey": "p3-high-32", - "script": "./p3-high-32.js", - "title": "Science and Filmmaking 电影科学(CGI)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/", - "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-33": { - "examId": "p1-medium-33", - "dataKey": "p1-medium-33", - "script": "./p1-medium-33.js", - "title": "The Pyramid of Cestius 罗马金字塔", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", - "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-34": { - "examId": "p1-low-34", - "dataKey": "p1-low-34", - "script": "./p1-low-34.js", - "title": "The Slow Food Organization 慢食运动组织", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/", - "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-35": { - "examId": "p1-low-35", - "dataKey": "p1-low-35", - "script": "./p1-low-35.js", - "title": "Sweet Trouble 澳洲制糖产业", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/", - "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-36": { - "examId": "p3-low-36", - "dataKey": "p3-low-36", - "script": "./p3-low-36.js", - "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/", - "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-37": { - "examId": "p2-low-37", - "dataKey": "p2-low-37", - "script": "./p2-low-37.js", - "title": "Keeping the water away 洪水防控", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", - "filename": "131. P2 - Keeping the water away 洪水防控.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-38": { - "examId": "p3-low-38", - "dataKey": "p3-low-38", - "script": "./p3-low-38.js", - "title": "Research into the effects of different teaching styles 教学风格研究", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/", - "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-39": { - "examId": "p2-low-39", - "dataKey": "p2-low-39", - "script": "./p2-low-39.js", - "title": "How to be Happy 如何获得幸福", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/", - "filename": "133. P2 - How to be Happy 如何获得幸福.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-40": { - "examId": "p1-low-40", - "dataKey": "p1-low-40", - "script": "./p1-low-40.js", - "title": "Dyes and fabric dyeing 染料的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/", - "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-41": { - "examId": "p2-low-41", - "dataKey": "p2-low-41", - "script": "./p2-low-41.js", - "title": "The Myth of the Eight-hour Sleep 八小时睡眠", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/", - "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-42": { - "examId": "p3-low-42", - "dataKey": "p3-low-42", - "script": "./p3-low-42.js", - "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", - "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-43": { - "examId": "p3-low-43", - "dataKey": "p3-low-43", - "script": "./p3-low-43.js", - "title": "What is social history 社会史", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", - "filename": "137. P3 - What is social history 社会史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-44": { - "examId": "p3-low-44", - "dataKey": "p3-low-44", - "script": "./p3-low-44.js", - "title": "Conformity 从众心理", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/", - "filename": "138. P3 - Conformity 从众心理.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-45": { - "examId": "p1-low-45", - "dataKey": "p1-low-45", - "script": "./p1-low-45.js", - "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", - "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-46": { - "examId": "p1-low-46", - "dataKey": "p1-low-46", - "script": "./p1-low-46.js", - "title": "Sydney Opera House 悉尼歌剧院", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", - "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-47": { - "examId": "p1-low-47", - "dataKey": "p1-low-47", - "script": "./p1-low-47.js", - "title": "The Burgess Shale fossils 伯吉斯页岩", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/", - "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-48": { - "examId": "p1-low-48", - "dataKey": "p1-low-48", - "script": "./p1-low-48.js", - "title": "The history of the guitar 吉他的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", - "filename": "141. P1 - The history of the guitar 吉他的历史.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p2-low-49": { - "examId": "p2-low-49", - "dataKey": "p2-low-49", - "script": "./p2-low-49.js", - "title": "Born to Trade 交易的本能", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/", - "filename": "142. P2 - Born to Trade 交易的本能.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-50": { - "examId": "p2-low-50", - "dataKey": "p2-low-50", - "script": "./p2-low-50.js", - "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", - "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-51": { - "examId": "p2-low-51", - "dataKey": "p2-low-51", - "script": "./p2-low-51.js", - "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/", - "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-52": { - "examId": "p1-low-52", - "dataKey": "p1-low-52", - "script": "./p1-low-52.js", - "title": "Caral an ancient South American city 卡拉尔古城", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", - "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-53": { - "examId": "p1-low-53", - "dataKey": "p1-low-53", - "script": "./p1-low-53.js", - "title": "The Early History of Olive Oil 橄榄油的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/", - "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-54": { - "examId": "p3-low-54", - "dataKey": "p3-low-54", - "script": "./p3-low-54.js", - "title": "Movement Underwater 水下运动", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", - "filename": "147. P3 - Movement Underwater 水下运动.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-55": { - "examId": "p3-low-55", - "dataKey": "p3-low-55", - "script": "./p3-low-55.js", - "title": "Improving Patient Safety 药品包装设计", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/", - "filename": "148. P3 - Improving Patient Safety 药品包装设计.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-56": { - "examId": "p3-low-56", - "dataKey": "p3-low-56", - "script": "./p3-low-56.js", - "title": "Learning to be bilingual 双语学习", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/", - "filename": "149. P3 - Learning to be bilingual 双语学习.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-57": { - "examId": "p1-medium-57", - "dataKey": "p1-medium-57", - "script": "./p1-medium-57.js", - "title": "The Blockbuster Phenomenon 博物馆爆款现象", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/", - "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-58": { - "examId": "p2-medium-58", - "dataKey": "p2-medium-58", - "script": "./p2-medium-58.js", - "title": "Insect Decision-Making 昆虫决策", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/", - "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-59": { - "examId": "p3-low-59", - "dataKey": "p3-low-59", - "script": "./p3-low-59.js", - "title": "Inside the mind of a fan 观赛心境", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/", - "filename": "151. P3 - Inside the mind of a fan 观赛心境.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-60": { - "examId": "p1-medium-60", - "dataKey": "p1-medium-60", - "script": "./p1-medium-60.js", - "title": "Sorry—who are you 脸盲症", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/", - "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-61": { - "examId": "p1-low-61", - "dataKey": "p1-low-61", - "script": "./p1-low-61.js", - "title": "Carnivorous plants 食虫植物", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/", - "filename": "153. P1 - Carnivorous plants 食虫植物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-62": { - "examId": "p2-low-62", - "dataKey": "p2-low-62", - "script": "./p2-low-62.js", - "title": "The purpose of facial expressions 面部表情", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/", - "filename": "154. P2 - The purpose of facial expressions 面部表情.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-63": { - "examId": "p1-medium-63", - "dataKey": "p1-medium-63", - "script": "./p1-medium-63.js", - "title": "A Brief History of Humans and Food 人类食物的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/", - "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-64": { - "examId": "p2-low-64", - "dataKey": "p2-low-64", - "script": "./p2-low-64.js", - "title": "New filter promises clean water for millions 新型泥土净水器", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/", - "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-65": { - "examId": "p2-low-65", - "dataKey": "p2-low-65", - "script": "./p2-low-65.js", - "title": "Boring Buildings 无聊建筑", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/", - "filename": "157. P2 - Boring Buildings 无聊建筑.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-66": { - "examId": "p3-medium-66", - "dataKey": "p3-medium-66", - "script": "./p3-medium-66.js", - "title": "Mercator - The Map Maker 地理制图师", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/", - "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-67": { - "examId": "p1-low-67", - "dataKey": "p1-low-67", - "script": "./p1-low-67.js", - "title": "Scented Plants 植物的味道", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/", - "filename": "159. P1 - Scented Plants 植物的味道.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-68": { - "examId": "p1-low-68", - "dataKey": "p1-low-68", - "script": "./p1-low-68.js", - "title": "The Clipper Races 帆船竞速", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", - "filename": "16. P1 - The Clipper Races 帆船竞速.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-69": { - "examId": "p1-low-69", - "dataKey": "p1-low-69", - "script": "./p1-low-69.js", - "title": "An important language development 楔形文字", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/", - "filename": "160. P1 - An important language development 楔形文字.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-70": { - "examId": "p1-low-70", - "dataKey": "p1-low-70", - "script": "./p1-low-70.js", - "title": "Fluorescence Deep sea discovery深海发光生物研究", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", - "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-71": { - "examId": "p3-low-71", - "dataKey": "p3-low-71", - "script": "./p3-low-71.js", - "title": "Sea Change for Salinity 土地盐碱化", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/", - "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-72": { - "examId": "p1-low-72", - "dataKey": "p1-low-72", - "script": "./p1-low-72.js", - "title": "How to find your way out of a food desert 城市食物荒漠", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/", - "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-73": { - "examId": "p2-low-73", - "dataKey": "p2-low-73", - "script": "./p2-low-73.js", - "title": "The Power of Smell 嗅觉的力量", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/", - "filename": "164. P2 - The Power of Smell 嗅觉的力量.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-74": { - "examId": "p3-low-74", - "dataKey": "p3-low-74", - "script": "./p3-low-74.js", - "title": "The Placebo Effect5 安慰剂效应", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/", - "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-75": { - "examId": "p2-low-75", - "dataKey": "p2-low-75", - "script": "./p2-low-75.js", - "title": "Lean Production Innovation 精益生产", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", - "filename": "166. P2 - Lean Production Innovation 精益生产.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-76": { - "examId": "p3-low-76", - "dataKey": "p3-low-76", - "script": "./p3-low-76.js", - "title": "Sign, Baby, Sign! 美国手语", - "category": "P3", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/", - "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-77": { - "examId": "p2-low-77", - "dataKey": "p2-low-77", - "script": "./p2-low-77.js", - "title": "Mammoth Kill 猛犸象的灭绝", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", - "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-78": { - "examId": "p3-low-78", - "dataKey": "p3-low-78", - "script": "./p3-low-78.js", - "title": "The Costs of Brand Loyalty 品牌忠诚的代价", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/", - "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-79": { - "examId": "p1-high-79", - "dataKey": "p1-high-79", - "script": "./p1-high-79.js", - "title": "The Development of The Silk Industry 丝绸产业发展", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/", - "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-80": { - "examId": "p1-low-80", - "dataKey": "p1-low-80", - "script": "./p1-low-80.js", - "title": "The unsung sense 被低估的嗅觉", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", - "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-81": { - "examId": "p1-low-81", - "dataKey": "p1-low-81", - "script": "./p1-low-81.js", - "title": "Salt 盐的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/", - "filename": "171. P1 - Salt 盐的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-82": { - "examId": "p1-high-82", - "dataKey": "p1-high-82", - "script": "./p1-high-82.js", - "title": "Think Small 微观科学", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", - "filename": "172. P1 - Think Small 微观科学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-83": { - "examId": "p3-low-83", - "dataKey": "p3-low-83", - "script": "./p3-low-83.js", - "title": "1018纸笔 Looking for inspiration 寻找灵感", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/", - "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-84": { - "examId": "p1-low-84", - "dataKey": "p1-low-84", - "script": "./p1-low-84.js", - "title": "Why good ideas fail TF公司", - "category": "P1", - "frequency": "low", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", - "filename": "174. P1 - Why good ideas fail TF公司.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-85": { - "examId": "p3-low-85", - "dataKey": "p3-low-85", - "script": "./p3-low-85.js", - "title": "Music soothes and awes 音乐疗愈", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/", - "filename": "175. P3 - Music soothes and awes 音乐疗愈.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-86": { - "examId": "p2-medium-86", - "dataKey": "p2-medium-86", - "script": "./p2-medium-86.js", - "title": "Urban Regeneration 柏林公园改造", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", - "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-87": { - "examId": "p2-low-87", - "dataKey": "p2-low-87", - "script": "./p2-low-87.js", - "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/", - "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-88": { - "examId": "p3-low-88", - "dataKey": "p3-low-88", - "script": "./p3-low-88.js", - "title": "1025纸笔Translating a key to international understanding 翻译的艺术", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/", - "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-89": { - "examId": "p3-high-89", - "dataKey": "p3-high-89", - "script": "./p3-high-89.js", - "title": "Looking at daily life in ancient Rome 古罗马的日常", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/", - "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-90": { - "examId": "p1-high-90", - "dataKey": "p1-high-90", - "script": "./p1-high-90.js", - "title": "The History of Tea 茶叶的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/", - "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-91": { - "examId": "p2-high-91", - "dataKey": "p2-high-91", - "script": "./p2-high-91.js", - "title": "Australia’s camouflaged creatures 澳洲伪装生物", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", - "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-92": { - "examId": "p1-high-92", - "dataKey": "p1-high-92", - "script": "./p1-high-92.js", - "title": "Dust and the American West 美国西部尘埃", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", - "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-93": { - "examId": "p2-medium-93", - "dataKey": "p2-medium-93", - "script": "./p2-medium-93.js", - "title": "Antarctic research 南极考察", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", - "filename": "182. P2 - Antarctic research 南极考察.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-94": { - "examId": "p2-low-94", - "dataKey": "p2-low-94", - "script": "./p2-low-94.js", - "title": "The importance of being playful 玩耍的重要性", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/", - "filename": "183. P2 - The importance of being playful 玩耍的重要性.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-95": { - "examId": "p3-low-95", - "dataKey": "p3-low-95", - "script": "./p3-low-95.js", - "title": "The strange world of sight 奇异的视觉世界", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", - "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-96": { - "examId": "p2-low-96", - "dataKey": "p2-low-96", - "script": "./p2-low-96.js", - "title": "[Pretest] Why Do We Need Sleep 睡眠的目的", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/", - "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-97": { - "examId": "p3-low-97", - "dataKey": "p3-low-97", - "script": "./p3-low-97.js", - "title": "Saving languages 拯救濒危语言", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/", - "filename": "186. P3 - Saving languages 拯救濒危语言.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-98": { - "examId": "p3-low-98", - "dataKey": "p3-low-98", - "script": "./p3-low-98.js", - "title": "Petrol power an eco-revolution 交通的革命", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/", - "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-99": { - "examId": "p1-low-99", - "dataKey": "p1-low-99", - "script": "./p1-low-99.js", - "title": "The history of the bar code 条形码的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", - "filename": "188. P1 - The history of the bar code 条形码的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-100": { - "examId": "p3-low-100", - "dataKey": "p3-low-100", - "script": "./p3-low-100.js", - "title": "Mirror 镜子研究", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/", - "filename": "189. P3 - Mirror 镜子研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-101": { - "examId": "p1-high-101", - "dataKey": "p1-high-101", - "script": "./p1-high-101.js", - "title": "The Impact of the Potato 土豆的影响", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/", - "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-102": { - "examId": "p2-low-102", - "dataKey": "p2-low-102", - "script": "./p2-low-102.js", - "title": "The power of music 音乐的力量", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/", - "filename": "190. P2 - The power of music 音乐的力量.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-103": { - "examId": "p2-low-103", - "dataKey": "p2-low-103", - "script": "./p2-low-103.js", - "title": "The economic effect of climate 气候对经济的影响", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", - "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-104": { - "examId": "p2-low-104", - "dataKey": "p2-low-104", - "script": "./p2-low-104.js", - "title": "1115纸笔Should we stop eating meat 是否应该吃素", - "category": "P2", - "frequency": "low", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", - "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-105": { - "examId": "p1-high-105", - "dataKey": "p1-high-105", - "script": "./p1-high-105.js", - "title": "A survivor’s story 新西兰猫头鹰", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/", - "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-106": { - "examId": "p1-low-106", - "dataKey": "p1-low-106", - "script": "./p1-low-106.js", - "title": "The Importance of Business Cards 名片的重要性", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/", - "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-107": { - "examId": "p1-low-107", - "dataKey": "p1-low-107", - "script": "./p1-low-107.js", - "title": "The life of Beatrix Potter 彼得兔作家", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", - "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-108": { - "examId": "p1-low-108", - "dataKey": "p1-low-108", - "script": "./p1-low-108.js", - "title": "The nature of Yawning 打哈欠的本质", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/", - "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-109": { - "examId": "p1-low-109", - "dataKey": "p1-low-109", - "script": "./p1-low-109.js", - "title": "The Origin of Paper 造纸术起源", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", - "filename": "23. P1 - The Origin of Paper 造纸术起源.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-110": { - "examId": "p1-high-110", - "dataKey": "p1-high-110", - "script": "./p1-high-110.js", - "title": "The Pearls 珍珠", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/", - "filename": "24. P1 - The Pearls 珍珠【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-111": { - "examId": "p1-low-111", - "dataKey": "p1-low-111", - "script": "./p1-low-111.js", - "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/", - "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-112": { - "examId": "p1-low-112", - "dataKey": "p1-low-112", - "script": "./p1-low-112.js", - "title": "The Tuatara of New Zealand 新西兰蜥蜴", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/", - "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-113": { - "examId": "p1-low-113", - "dataKey": "p1-low-113", - "script": "./p1-low-113.js", - "title": "Thomas Young The last man who knew everything 托马斯·杨", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", - "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-114": { - "examId": "p1-low-114", - "dataKey": "p1-low-114", - "script": "./p1-low-114.js", - "title": "Triumph of the City 城市的胜利", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 1.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", - "filename": "28. P1 - Triumph of the City 城市的胜利.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-115": { - "examId": "p1-medium-115", - "dataKey": "p1-medium-115", - "script": "./p1-medium-115.js", - "title": "Tunnelling under the Thames 泰晤士河隧道", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", - "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-116": { - "examId": "p1-low-116", - "dataKey": "p1-low-116", - "script": "./p1-low-116.js", - "title": "Advertising Needs Attention 广告的吸引力", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/", - "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-117": { - "examId": "p1-medium-117", - "dataKey": "p1-medium-117", - "script": "./p1-medium-117.js", - "title": "What Lucy Taught Us 露西化石", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", - "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-118": { - "examId": "p1-high-118", - "dataKey": "p1-high-118", - "script": "./p1-high-118.js", - "title": "William Gilbert and Magnetism 电磁学之父", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/", - "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-119": { - "examId": "p1-medium-119", - "dataKey": "p1-medium-119", - "script": "./p1-medium-119.js", - "title": "Wood 新西兰木材产业", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", - "filename": "32. P1 - Wood 新西兰木材产业【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-120": { - "examId": "p2-high-120", - "dataKey": "p2-high-120", - "script": "./p2-high-120.js", - "title": "A new look for Talbot Park 奥克兰社区改造", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/", - "filename": "ai_studio_code (9).html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-121": { - "examId": "p2-medium-121", - "dataKey": "p2-medium-121", - "script": "./p2-medium-121.js", - "title": "A unique golden textile 蜘蛛丝", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/", - "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-122": { - "examId": "p2-low-122", - "dataKey": "p2-low-122", - "script": "./p2-low-122.js", - "title": "Biophilic Design 亲自然设计", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", - "filename": "35. P2 - Biophilic Design 亲自然设计.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-123": { - "examId": "p2-high-123", - "dataKey": "p2-high-123", - "script": "./p2-high-123.js", - "title": "Bird Migration 鸟类迁徙", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/", - "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-124": { - "examId": "p2-high-124", - "dataKey": "p2-high-124", - "script": "./p2-high-124.js", - "title": "Corporate Social Responsibility 企业社会责任", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", - "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-125": { - "examId": "p2-low-125", - "dataKey": "p2-low-125", - "script": "./p2-low-125.js", - "title": "Egypt’s ancient boat-builders 古埃及造船", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/", - "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-126": { - "examId": "p2-medium-126", - "dataKey": "p2-medium-126", - "script": "./p2-medium-126.js", - "title": "How are deserts formed 沙漠成因", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/", - "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-127": { - "examId": "p1-low-127", - "dataKey": "p1-low-127", - "script": "./p1-low-127.js", - "title": "Ambergris 龙涎香", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", - "filename": "4. P1 - Ambergris 龙涎香.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-128": { - "examId": "p2-high-128", - "dataKey": "p2-high-128", - "script": "./p2-high-128.js", - "title": "How Well Do We Concentrate_ 多任务处理", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/", - "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-129": { - "examId": "p2-medium-129", - "dataKey": "p2-medium-129", - "script": "./p2-medium-129.js", - "title": "Intelligent behaviour in birds 鸟类智慧行为", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", - "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-130": { - "examId": "p2-high-130", - "dataKey": "p2-high-130", - "script": "./p2-high-130.js", - "title": "Investment in shares versus investment in other assets 回报数据分析", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", - "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-131": { - "examId": "p2-high-131", - "dataKey": "p2-high-131", - "script": "./p2-high-131.js", - "title": "Learning from the Romans 罗马混凝土", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/", - "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-132": { - "examId": "p2-low-132", - "dataKey": "p2-low-132", - "script": "./p2-low-132.js", - "title": "Orientation of Birds 鸟类的定位能力", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/", - "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-133": { - "examId": "p2-high-133", - "dataKey": "p2-high-133", - "script": "./p2-high-133.js", - "title": "Playing soccer 街头足球", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/", - "filename": "45. P2 - Playing soccer 街头足球【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-134": { - "examId": "p2-high-134", - "dataKey": "p2-high-134", - "script": "./p2-high-134.js", - "title": "Roller coaster 过山车", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/", - "filename": "46. P2 - Roller coaster 过山车【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-135": { - "examId": "p2-low-135", - "dataKey": "p2-low-135", - "script": "./p2-low-135.js", - "title": "Skyscraper Farming 摩天大楼种植", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", - "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-136": { - "examId": "p2-high-136", - "dataKey": "p2-high-136", - "script": "./p2-high-136.js", - "title": "Solving the problem of waste disposal 垃圾处理", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/", - "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-137": { - "examId": "p2-high-137", - "dataKey": "p2-high-137", - "script": "./p2-high-137.js", - "title": "Surviving city life 动物适应城市", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", - "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-138": { - "examId": "p1-low-138", - "dataKey": "p1-low-138", - "script": "./p1-low-138.js", - "title": "Australian artist Margaret Preston 澳大利亚艺术家", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/", - "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-139": { - "examId": "p2-high-139", - "dataKey": "p2-high-139", - "script": "./p2-high-139.js", - "title": "The conquest of malaria in Italy 意大利疟疾防治", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", - "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-140": { - "examId": "p2-low-140", - "dataKey": "p2-low-140", - "script": "./p2-low-140.js", - "title": "The dingo debate 澳洲野犬", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/", - "filename": "51. P2 - The dingo debate 澳洲野犬.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-141": { - "examId": "p2-high-141", - "dataKey": "p2-high-141", - "script": "./p2-high-141.js", - "title": "The fascinating world of attine ants 切叶蚁", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/", - "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-142": { - "examId": "p2-low-142", - "dataKey": "p2-low-142", - "script": "./p2-low-142.js", - "title": "The fashion industry 时尚产业", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", - "filename": "53. P2 - The fashion industry 时尚产业.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-143": { - "examId": "p2-low-143", - "dataKey": "p2-low-143", - "script": "./p2-low-143.js", - "title": "The impact of invasive species 入侵物种的影响", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", - "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-144": { - "examId": "p2-medium-144", - "dataKey": "p2-medium-144", - "script": "./p2-medium-144.js", - "title": "The plan to bring an asteroid to Earth 捕获小行星", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/", - "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-145": { - "examId": "p2-high-145", - "dataKey": "p2-high-145", - "script": "./p2-high-145.js", - "title": "The return of monkey life 猴群回归", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", - "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-146": { - "examId": "p2-medium-146", - "dataKey": "p2-medium-146", - "script": "./p2-medium-146.js", - "title": "The Tasmanian Tiger 袋狼", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/", - "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-147": { - "examId": "p2-low-147", - "dataKey": "p2-low-147", - "script": "./p2-low-147.js", - "title": "Who wrote Shakespeare's plays 莎士比亚", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", - "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-148": { - "examId": "p2-low-148", - "dataKey": "p2-low-148", - "script": "./p2-low-148.js", - "title": "Why do we need the arts_ 艺术的意义", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/", - "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-149": { - "examId": "p1-low-149", - "dataKey": "p1-low-149", - "script": "./p1-low-149.js", - "title": "Categorizing societies 社会分类", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/", - "filename": "6. P1 - Categorizing societies 社会分类html.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-150": { - "examId": "p3-high-150", - "dataKey": "p3-high-150", - "script": "./p3-high-150.js", - "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/", - "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-151": { - "examId": "p3-low-151", - "dataKey": "p3-low-151", - "script": "./p3-low-151.js", - "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/", - "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-152": { - "examId": "p3-medium-152", - "dataKey": "p3-medium-152", - "script": "./p3-medium-152.js", - "title": "Charles Darwin and Evolutionary Psychology 进化心理学", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", - "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-153": { - "examId": "p3-low-153", - "dataKey": "p3-low-153", - "script": "./p3-low-153.js", - "title": "Crossing the Threshold 奥克兰美术馆", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/", - "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-154": { - "examId": "p3-medium-154", - "dataKey": "p3-medium-154", - "script": "./p3-medium-154.js", - "title": "Decisions, Decisions 决策之间", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/", - "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-155": { - "examId": "p3-medium-155", - "dataKey": "p3-medium-155", - "script": "./p3-medium-155.js", - "title": "Does class size matter_ 课堂规模", - "category": "P3", - "frequency": "low", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", - "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-156": { - "examId": "p3-high-156", - "dataKey": "p3-high-156", - "script": "./p3-high-156.js", - "title": "Elephant Communication 大象交流", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/", - "filename": "66. P3 - Elephant Communication 大象交流【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-157": { - "examId": "p3-high-157", - "dataKey": "p3-high-157", - "script": "./p3-high-157.js", - "title": "Flower Power 鲜花的力量(花之力)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", - "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf", - "sourceKind": "generated-reading" - }, - "p3-low-158": { - "examId": "p3-low-158", - "dataKey": "p3-low-158", - "script": "./p3-low-158.js", - "title": "Game theory 博弈论", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/", - "filename": "68. P3 - Game theory 博弈论.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-159": { - "examId": "p3-high-159", - "dataKey": "p3-high-159", - "script": "./p3-high-159.js", - "title": "Grimm’s Fairy Tales 格林童话", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/", - "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-160": { - "examId": "p1-low-160", - "dataKey": "p1-low-160", - "script": "./p1-low-160.js", - "title": "Chili peppers 辣椒的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/", - "filename": "7. P1 - Chili peppers 辣椒的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-161": { - "examId": "p3-high-161", - "dataKey": "p3-high-161", - "script": "./p3-high-161.js", - "title": "Insect-inspired robots 昆虫机器人", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/", - "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-162": { - "examId": "p3-medium-162", - "dataKey": "p3-medium-162", - "script": "./p3-medium-162.js", - "title": "Jean Piaget (1896–1980) 让·皮亚杰", - "category": "P3", - "frequency": "low", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", - "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-163": { - "examId": "p3-low-163", - "dataKey": "p3-low-163", - "script": "./p3-low-163.js", - "title": "Keeping the Fun in Funfairs 游乐场设计科学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/", - "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-164": { - "examId": "p3-high-164", - "dataKey": "p3-high-164", - "script": "./p3-high-164.js", - "title": "Language Strategy in Multinational Companies 跨国公司语言策略", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/", - "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-165": { - "examId": "p3-low-165", - "dataKey": "p3-low-165", - "script": "./p3-low-165.js", - "title": "Let’s teach them how to teach 教他们如何教学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/", - "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-166": { - "examId": "p3-low-166", - "dataKey": "p3-low-166", - "script": "./p3-low-166.js", - "title": "Life on Mars_ 火星地球化改造", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", - "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-167": { - "examId": "p3-high-167", - "dataKey": "p3-high-167", - "script": "./p3-high-167.js", - "title": "Living dunes 流动沙丘", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/", - "filename": "76. P3 - Living dunes 流动沙丘【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-168": { - "examId": "p3-medium-168", - "dataKey": "p3-medium-168", - "script": "./p3-medium-168.js", - "title": "Marketing and the information age 信息时代营销", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", - "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-169": { - "examId": "p3-medium-169", - "dataKey": "p3-medium-169", - "script": "./p3-medium-169.js", - "title": "(无题目) Music Language We All Speak 音乐语言", - "category": "P3", - "frequency": "low", - "difficultyScore": 4.5, - "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", - "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-170": { - "examId": "p3-high-170", - "dataKey": "p3-high-170", - "script": "./p3-high-170.js", - "title": "Pacific Navigation and Voyaging 太平洋航海", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/", - "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-171": { - "examId": "p1-high-171", - "dataKey": "p1-high-171", - "script": "./p1-high-171.js", - "title": "Fishbourne Roman Palace 罗马宫殿", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/", - "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-172": { - "examId": "p3-low-172", - "dataKey": "p3-low-172", - "script": "./p3-low-172.js", - "title": "Rebranding art museums 博物馆品牌重塑", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/", - "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-173": { - "examId": "p3-high-173", - "dataKey": "p3-high-173", - "script": "./p3-high-173.js", - "title": "Robert Louis Stevenson 苏格兰作家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", - "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-174": { - "examId": "p3-high-174", - "dataKey": "p3-high-174", - "script": "./p3-high-174.js", - "title": "Some views on the use of headphones 耳机使用", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/", - "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-175": { - "examId": "p3-low-175", - "dataKey": "p3-low-175", - "script": "./p3-low-175.js", - "title": "Termite Mounds 白蚁丘", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/", - "filename": "83. P3 - Termite Mounds 白蚁丘.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-176": { - "examId": "p3-medium-176", - "dataKey": "p3-medium-176", - "script": "./p3-medium-176.js", - "title": "The Analysis of Fear 猴子恐惧实验", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", - "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-177": { - "examId": "p3-medium-177", - "dataKey": "p3-medium-177", - "script": "./p3-medium-177.js", - "title": "The Art of Deception 欺骗的艺术", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/", - "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-178": { - "examId": "p3-high-178", - "dataKey": "p3-high-178", - "script": "./p3-high-178.js", - "title": "The benefits of learning an instrument 学乐器的好处", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/", - "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-179": { - "examId": "p3-medium-179", - "dataKey": "p3-medium-179", - "script": "./p3-medium-179.js", - "title": "The Exploration of Mars 火星探索", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/", - "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-180": { - "examId": "p3-high-180", - "dataKey": "p3-high-180", - "script": "./p3-high-180.js", - "title": "The fluoridation controversy 氟化水争议", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/", - "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-181": { - "examId": "p3-high-181", - "dataKey": "p3-high-181", - "script": "./p3-high-181.js", - "title": "The Fruit Book 果实之书", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/", - "filename": "89. P3 - The Fruit Book 果实之书【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-182": { - "examId": "p1-medium-182", - "dataKey": "p1-medium-182", - "script": "./p1-medium-182.js", - "title": "Listening to the Ocean 海洋探测", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", - "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-183": { - "examId": "p3-medium-183", - "dataKey": "p3-medium-183", - "script": "./p3-medium-183.js", - "title": "The hazards of multitasking 多任务处理", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", - "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-184": { - "examId": "p3-high-184", - "dataKey": "p3-high-184", - "script": "./p3-high-184.js", - "title": "The New Zealand writer Margaret Mahy 新西兰女作家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/", - "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-185": { - "examId": "p3-medium-185", - "dataKey": "p3-medium-185", - "script": "./p3-medium-185.js", - "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/", - "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-186": { - "examId": "p3-low-186", - "dataKey": "p3-low-186", - "script": "./p3-low-186.js", - "title": "The Robbers Cave Study (山洞)群体行为实验", - "category": "P3", - "frequency": "low", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", - "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-187": { - "examId": "p3-low-187", - "dataKey": "p3-low-187", - "script": "./p3-low-187.js", - "title": "The science of sleep 睡眠的科学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/", - "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-188": { - "examId": "p3-medium-188", - "dataKey": "p3-medium-188", - "script": "./p3-medium-188.js", - "title": "The Significant Role of Mother Tongue in Education 母语教育", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/", - "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-189": { - "examId": "p3-high-189", - "dataKey": "p3-high-189", - "script": "./p3-high-189.js", - "title": "The tuatara – past and future 新西兰蜥蜴", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/", - "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-190": { - "examId": "p3-low-190", - "dataKey": "p3-low-190", - "script": "./p3-low-190.js", - "title": "The value of literary prizes 文学奖项的价值", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/", - "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-191": { - "examId": "p3-medium-191", - "dataKey": "p3-medium-191", - "script": "./p3-medium-191.js", - "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/", - "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-192": { - "examId": "p3-high-192", - "dataKey": "p3-high-192", - "script": "./p3-high-192.js", - "title": "Voynich Manuscript 伏尼契手稿", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/", - "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-200": { - "examId": "p1-high-200", - "dataKey": "p1-high-200", - "script": "./p1-high-200.js", - "title": "Australia’s Airborne Dentists 澳洲飞行牙医", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "三月/1.P1 高频/", - "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-211": { - "examId": "p1-high-211", - "dataKey": "p1-high-211", - "script": "./p1-high-211.js", - "title": "Ahead of its time 新西兰头骨", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "三月/1.P1 高频/", - "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-216": { - "examId": "p1-high-216", - "dataKey": "p1-high-216", - "script": "./p1-high-216.js", - "title": "Australia’s cane toad problem 澳洲蟾蜍", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "三月/1.P1 高频/", - "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-194": { - "examId": "p1-high-194", - "dataKey": "p1-high-194", - "script": "./p1-high-194.js", - "title": "The history of the British wool industry 英国羊毛产业的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "三月/2.P1 次高频/", - "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-222": { - "examId": "p2-low-222", - "dataKey": "p2-low-222", - "script": "./p2-low-222.js", - "title": "Ideal Homes 理想居所", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/", - "filename": "222. P2 - Ideal Homes 理想居所.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-223": { - "examId": "p1-low-223", - "dataKey": "p1-low-223", - "script": "./p1-low-223.js", - "title": "Effect and Cause 湖泊海啸研究", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "三月/", - "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-201": { - "examId": "p2-high-201", - "dataKey": "p2-high-201", - "script": "./p2-high-201.js", - "title": "Multi-tasking and the brain 大脑与多任务处理", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "三月/3.P2 高频/", - "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-217": { - "examId": "p2-medium-217", - "dataKey": "p2-medium-217", - "script": "./p2-medium-217.js", - "title": "A mechanical friend for children 孩子的机器人朋友", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "三月/3.P2 高频/", - "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-192": { - "examId": "p2-high-192", - "dataKey": "p2-high-192", - "script": "./p2-high-192.js", - "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", - "category": "P2", - "frequency": "low", - "difficultyScore": 3.5, - "path": "三月/4.P2 次高频/", - "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-209": { - "examId": "p2-medium-209", - "dataKey": "p2-medium-209", - "script": "./p2-medium-209.js", - "title": "Decision Fatigue 决策疲劳", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-213": { - "examId": "p2-medium-213", - "dataKey": "p2-medium-213", - "script": "./p2-medium-213.js", - "title": "Growing more for less 卫星农业", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "三月/4.P2 次高频/", - "filename": "213. P2 - Growing more for less 卫星农业【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-051": { - "examId": "p2-low-051", - "dataKey": "p2-low-051", - "script": "./p2-low-051.js", - "title": "The dingo debate 澳洲野犬_澳洲野狗", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-058": { - "examId": "p2-medium-058", - "dataKey": "p2-medium-058", - "script": "./p2-medium-058.js", - "title": "Who wrote Shakespeare's plays 莎士比亚", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-204": { - "examId": "p3-high-204", - "dataKey": "p3-high-204", - "script": "./p3-high-204.js", - "title": "When people are ‘deaf’ to music 失乐症", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-206": { - "examId": "p3-high-206", - "dataKey": "p3-high-206", - "script": "./p3-high-206.js", - "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-212": { - "examId": "p3-high-212", - "dataKey": "p3-high-212", - "script": "./p3-high-212.js", - "title": "Children’s literature studies today 儿童文学", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-218": { - "examId": "p3-high-218", - "dataKey": "p3-high-218", - "script": "./p3-high-218.js", - "title": "The Causes of Linguistic Change 语音的演变", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-219": { - "examId": "p3-low-219", - "dataKey": "p3-low-219", - "script": "./p3-low-219.js", - "title": "The origin of language 语言的起源", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "219. P3 - The origin of language 语言的起源.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-999": { - "examId": "p3-low-999", - "dataKey": "p3-low-999", - "script": "./p3-low-999.js", - "title": "Risk taking", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "P3 - Risk taking.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p3-medium-197": { - "examId": "p3-medium-197", - "dataKey": "p3-medium-197", - "script": "./p3-medium-197.js", - "title": "Australia’s Megafauna Controversy 巨兽灭绝", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/6.P3 次高频/", - "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-198": { - "examId": "p3-low-198", - "dataKey": "p3-low-198", - "script": "./p3-low-198.js", - "title": "Child’s Play in Medieval England 中世纪的游戏", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "三月/6.P3 次高频/", - "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-078": { - "examId": "p3-low-078", - "dataKey": "p3-low-078", - "script": "./p3-low-078.js", - "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", - "category": "P3", - "frequency": "low", - "difficultyScore": 4.5, - "path": "三月/6.P3 次高频/", - "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-227": { - "examId": "p1-high-227", - "dataKey": "p1-high-227", - "script": "./p1-high-227.js", - "title": "The Whale Goes to Court 鲸鱼油", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "ReadingPractice/PDF/", - "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-225": { - "examId": "p2-high-225", - "dataKey": "p2-high-225", - "script": "./p2-high-225.js", - "title": "The problem of graffiti 涂鸦之困", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "ReadingPractice/PDF/", - "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-228": { - "examId": "p3-high-228", - "dataKey": "p3-high-228", - "script": "./p3-high-228.js", - "title": "On art and artists 艺术与艺术家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "ReadingPractice/PDF/", - "filename": "228. P3 - On art and artists 艺术与艺术家.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-229": { - "examId": "p1-high-229", - "dataKey": "p1-high-229", - "script": "./p1-high-229.js", - "title": "New Understanding of Giraffes in the Wild 野生长颈鹿", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "ReadingPractice/PDF/", - "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-230": { - "examId": "p1-high-230", - "dataKey": "p1-high-230", - "script": "./p1-high-230.js", - "title": "The History of the Pencil 铅笔的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1.5, - "path": "ReadingPractice/PDF/", - "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-231": { - "examId": "p1-high-231", - "dataKey": "p1-high-231", - "script": "./p1-high-231.js", - "title": "The History of the Pencil 铅笔的历史(流程图版)", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "ReadingPractice/PDF/", - "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", - "sourceKind": "generated-reading" - }, - "p2-high-232": { - "examId": "p2-high-232", - "dataKey": "p2-high-232", - "script": "./p2-high-232.js", - "title": "The origin and development of applause 掌声的历史", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "ReadingPractice/PDF/", - "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-233": { - "examId": "p2-high-233", - "dataKey": "p2-high-233", - "script": "./p2-high-233.js", - "title": "Why don’t we sleep 失眠的原因", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "ReadingPractice/PDF/", - "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-234": { - "examId": "p2-high-234", - "dataKey": "p2-high-234", - "script": "./p2-high-234.js", - "title": "How do plants talk to each other 植物交流", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "ReadingPractice/PDF/", - "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-221": { - "examId": "p3-high-221", - "dataKey": "p3-high-221", - "script": "./p3-high-221.js", - "title": "The Animal Connection 动物联结", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "221. P3 - The Animal Connection 动物联结.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-235": { - "examId": "p2-high-235", - "dataKey": "p2-high-235", - "script": "./p2-high-235.js", - "title": "The return of the black-footed ferret 黑足鼬", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-236": { - "examId": "p2-high-236", - "dataKey": "p2-high-236", - "script": "./p2-high-236.js", - "title": "War of the Plants 植物的战争", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "", - "filename": "", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p3-high-229": { - "examId": "p3-high-229", - "dataKey": "p3-high-229", - "script": "./p3-high-229.js", - "title": "All in the family 兄弟姐妹的影响", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-239": { - "examId": "p2-high-239", - "dataKey": "p2-high-239", - "script": "./p2-high-239.js", - "title": "Nanotechnology: the science of the very small 纳米科技", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-240": { - "examId": "p2-low-240", - "dataKey": "p2-low-240", - "script": "./p2-low-240.js", - "title": "Coins - the first form of money 硬币起源", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "assets/generated/reading-exams/", - "filename": "reading-practice-unified.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p1-high-240": { - "examId": "p1-high-240", - "dataKey": "p1-high-240", - "script": "./p1-high-240.js", - "title": "The Origins of Weather Forecasting 天气预报", - "category": "P1", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-242": { - "examId": "p2-low-242", - "dataKey": "p2-low-242", - "script": "./p2-low-242.js", - "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-240": { - "examId": "p3-low-240", - "dataKey": "p3-low-240", - "script": "./p3-low-240.js", - "title": "How a prehistoric predator took to the skies 翼龙飞行", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - How a prehistoric predator took to the skies.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-241": { - "examId": "p3-medium-241", - "dataKey": "p3-medium-241", - "script": "./p3-medium-241.js", - "title": "Who looks after the children in today's Britain? 育儿分工", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - Who looks after the children in today's Britain.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", - "sourceKind": "generated-reading" - } + "p1-high-01": { + "examId": "p1-high-01", + "dataKey": "p1-high-01", + "script": "./p1-high-01.js", + "title": "A Brief History of Tea 茶叶简史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/", + "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-02": { + "examId": "p1-low-02", + "dataKey": "p1-low-02", + "script": "./p1-low-02.js", + "title": "Maori Fish Hooks 毛利鱼钩", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/", + "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-03": { + "examId": "p3-high-03", + "dataKey": "p3-high-03", + "script": "./p3-high-03.js", + "title": "What makes a musical expert_ 音乐天赋", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", + "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-04": { + "examId": "p3-high-04", + "dataKey": "p3-high-04", + "script": "./p3-high-04.js", + "title": "Yawning 打呵欠", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", + "filename": "101. P3 - Yawning 打呵欠【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-05": { + "examId": "p1-high-05", + "dataKey": "p1-high-05", + "script": "./p1-high-05.js", + "title": "Katherine Mansfield 新西兰作家", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/", + "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-06": { + "examId": "p2-low-06", + "dataKey": "p2-low-06", + "script": "./p2-low-06.js", + "title": "Biomimicry 仿生学", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", + "filename": "103. P2 - Biomimicry 仿生学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-07": { + "examId": "p3-low-07", + "dataKey": "p3-low-07", + "script": "./p3-low-07.js", + "title": "Star Performers 明星员工", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", + "filename": "104. P3 - Star Performers 明星员工.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-08": { + "examId": "p2-low-08", + "dataKey": "p2-low-08", + "script": "./p2-low-08.js", + "title": "How the Petri dish supports scientific advances 培养皿", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/", + "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-09": { + "examId": "p2-high-09", + "dataKey": "p2-high-09", + "script": "./p2-high-09.js", + "title": "Early Approaches to Organisational Design 组织设计", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", + "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-10": { + "examId": "p2-medium-10", + "dataKey": "p2-medium-10", + "script": "./p2-medium-10.js", + "title": "A study of western celebrity 西方名人", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/", + "filename": "107. P2 - A study of western celebrity 西方名人【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-11": { + "examId": "p1-low-11", + "dataKey": "p1-low-11", + "script": "./p1-low-11.js", + "title": "Bovids 牛科动物", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/", + "filename": "108. P1 - Bovids 牛科动物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-12": { + "examId": "p3-low-12", + "dataKey": "p3-low-12", + "script": "./p3-low-12.js", + "title": "Humanities and the health professional 人文医学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/", + "filename": "109. P3 - Humanities and the health professional 人文医学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-13": { + "examId": "p1-low-13", + "dataKey": "p1-low-13", + "script": "./p1-low-13.js", + "title": "Report on a university drama project 大学戏剧项目报告", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", + "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-14": { + "examId": "p2-high-14", + "dataKey": "p2-high-14", + "script": "./p2-high-14.js", + "title": "Should space be explored by robots or by humans 人机太空探索", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", + "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-15": { + "examId": "p3-high-15", + "dataKey": "p3-high-15", + "script": "./p3-high-15.js", + "title": "Whale Culture 鲸鱼文化", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", + "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-16": { + "examId": "p2-high-16", + "dataKey": "p2-high-16", + "script": "./p2-high-16.js", + "title": "The Importance of Law 法律的意义", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/", + "filename": "112. P2 - The Importance of Law 法律的意义【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-17": { + "examId": "p2-high-17", + "dataKey": "p2-high-17", + "script": "./p2-high-17.js", + "title": "Herbal Medicines 新西兰草药", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/", + "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-18": { + "examId": "p3-medium-18", + "dataKey": "p3-medium-18", + "script": "./p3-medium-18.js", + "title": "Unlocking the mystery of dreams 梦的解析", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/", + "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-19": { + "examId": "p2-high-19", + "dataKey": "p2-high-19", + "script": "./p2-high-19.js", + "title": "Mind Music 脑海中的音乐(心灵音乐)", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", + "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-20": { + "examId": "p1-medium-20", + "dataKey": "p1-medium-20", + "script": "./p1-medium-20.js", + "title": "The Development of Plastics 塑料的发展史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/", + "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-21": { + "examId": "p2-high-21", + "dataKey": "p2-high-21", + "script": "./p2-high-21.js", + "title": "Stress Less 工作压力", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", + "filename": "117. P2 - Stress Less 工作压力【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-22": { + "examId": "p3-medium-22", + "dataKey": "p3-medium-22", + "script": "./p3-medium-22.js", + "title": "Neanderthal Technology 尼安德特人的生存技艺", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", + "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-23": { + "examId": "p2-high-23", + "dataKey": "p2-high-23", + "script": "./p2-high-23.js", + "title": "The Constant Evolution of the Humble Tomato 番茄的演化", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", + "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-24": { + "examId": "p1-high-24", + "dataKey": "p1-high-24", + "script": "./p1-high-24.js", + "title": "Rubber 橡胶", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/", + "filename": "12. P1 - Rubber 橡胶【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-25": { + "examId": "p2-high-25", + "dataKey": "p2-high-25", + "script": "./p2-high-25.js", + "title": "Will Eating Less Make You Live Longer 节食与长寿", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", + "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-27": { + "examId": "p1-high-27", + "dataKey": "p1-high-27", + "script": "./p1-high-27.js", + "title": "Footprints in the Mud 恐龙脚印", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", + "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-28": { + "examId": "p3-low-28", + "dataKey": "p3-low-28", + "script": "./p3-low-28.js", + "title": "Images and Places 风景与印记", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/", + "filename": "123. P3 - Images and Places 风景与印记.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-29": { + "examId": "p1-medium-29", + "dataKey": "p1-medium-29", + "script": "./p1-medium-29.js", + "title": "The extinction of the cave bear 洞熊的灭绝", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/", + "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-30": { + "examId": "p1-low-30", + "dataKey": "p1-low-30", + "script": "./p1-low-30.js", + "title": "Investing in the Future 投资未来", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", + "filename": "125. P1 - Investing in the Future 投资未来.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-31": { + "examId": "p1-high-31", + "dataKey": "p1-high-31", + "script": "./p1-high-31.js", + "title": "Dolls through the ages 玩偶的变迁史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/", + "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-32": { + "examId": "p3-high-32", + "dataKey": "p3-high-32", + "script": "./p3-high-32.js", + "title": "Science and Filmmaking 电影科学(CGI)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/", + "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-33": { + "examId": "p1-medium-33", + "dataKey": "p1-medium-33", + "script": "./p1-medium-33.js", + "title": "The Pyramid of Cestius 罗马金字塔", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", + "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-34": { + "examId": "p1-low-34", + "dataKey": "p1-low-34", + "script": "./p1-low-34.js", + "title": "The Slow Food Organization 慢食运动组织", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/", + "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-35": { + "examId": "p1-low-35", + "dataKey": "p1-low-35", + "script": "./p1-low-35.js", + "title": "Sweet Trouble 澳洲制糖产业", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/", + "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-36": { + "examId": "p3-low-36", + "dataKey": "p3-low-36", + "script": "./p3-low-36.js", + "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/", + "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-37": { + "examId": "p2-low-37", + "dataKey": "p2-low-37", + "script": "./p2-low-37.js", + "title": "Keeping the water away 洪水防控", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", + "filename": "131. P2 - Keeping the water away 洪水防控.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-38": { + "examId": "p3-low-38", + "dataKey": "p3-low-38", + "script": "./p3-low-38.js", + "title": "Research into the effects of different teaching styles 教学风格研究", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/", + "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-39": { + "examId": "p2-low-39", + "dataKey": "p2-low-39", + "script": "./p2-low-39.js", + "title": "How to be Happy 如何获得幸福", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/", + "filename": "133. P2 - How to be Happy 如何获得幸福.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-40": { + "examId": "p1-low-40", + "dataKey": "p1-low-40", + "script": "./p1-low-40.js", + "title": "Dyes and fabric dyeing 染料的历史", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/", + "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-41": { + "examId": "p2-low-41", + "dataKey": "p2-low-41", + "script": "./p2-low-41.js", + "title": "The Myth of the Eight-hour Sleep 八小时睡眠", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/", + "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-42": { + "examId": "p3-low-42", + "dataKey": "p3-low-42", + "script": "./p3-low-42.js", + "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", + "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-43": { + "examId": "p3-low-43", + "dataKey": "p3-low-43", + "script": "./p3-low-43.js", + "title": "What is social history 社会史", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", + "filename": "137. P3 - What is social history 社会史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-44": { + "examId": "p3-low-44", + "dataKey": "p3-low-44", + "script": "./p3-low-44.js", + "title": "Conformity 从众心理", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/", + "filename": "138. P3 - Conformity 从众心理.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-45": { + "examId": "p1-low-45", + "dataKey": "p1-low-45", + "script": "./p1-low-45.js", + "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", + "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-46": { + "examId": "p1-low-46", + "dataKey": "p1-low-46", + "script": "./p1-low-46.js", + "title": "Sydney Opera House 悉尼歌剧院", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", + "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-47": { + "examId": "p1-low-47", + "dataKey": "p1-low-47", + "script": "./p1-low-47.js", + "title": "The Burgess Shale fossils 伯吉斯页岩", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/", + "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-48": { + "examId": "p1-low-48", + "dataKey": "p1-low-48", + "script": "./p1-low-48.js", + "title": "The history of the guitar 吉他的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", + "filename": "141. P1 - The history of the guitar 吉他的历史.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p2-low-49": { + "examId": "p2-low-49", + "dataKey": "p2-low-49", + "script": "./p2-low-49.js", + "title": "Born to Trade 交易的本能", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/", + "filename": "142. P2 - Born to Trade 交易的本能.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-50": { + "examId": "p2-low-50", + "dataKey": "p2-low-50", + "script": "./p2-low-50.js", + "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", + "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-51": { + "examId": "p2-low-51", + "dataKey": "p2-low-51", + "script": "./p2-low-51.js", + "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/", + "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-52": { + "examId": "p1-low-52", + "dataKey": "p1-low-52", + "script": "./p1-low-52.js", + "title": "Caral an ancient South American city 卡拉尔古城", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", + "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-53": { + "examId": "p1-low-53", + "dataKey": "p1-low-53", + "script": "./p1-low-53.js", + "title": "The Early History of Olive Oil 橄榄油的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/", + "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-54": { + "examId": "p3-low-54", + "dataKey": "p3-low-54", + "script": "./p3-low-54.js", + "title": "Movement Underwater 水下运动", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", + "filename": "147. P3 - Movement Underwater 水下运动.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-55": { + "examId": "p3-low-55", + "dataKey": "p3-low-55", + "script": "./p3-low-55.js", + "title": "Improving Patient Safety 药品包装设计", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/", + "filename": "148. P3 - Improving Patient Safety 药品包装设计.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-56": { + "examId": "p3-low-56", + "dataKey": "p3-low-56", + "script": "./p3-low-56.js", + "title": "Learning to be bilingual 双语学习", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/", + "filename": "149. P3 - Learning to be bilingual 双语学习.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-57": { + "examId": "p1-medium-57", + "dataKey": "p1-medium-57", + "script": "./p1-medium-57.js", + "title": "The Blockbuster Phenomenon 博物馆爆款现象", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/", + "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-58": { + "examId": "p2-medium-58", + "dataKey": "p2-medium-58", + "script": "./p2-medium-58.js", + "title": "Insect Decision-Making 昆虫决策", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/", + "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-59": { + "examId": "p3-low-59", + "dataKey": "p3-low-59", + "script": "./p3-low-59.js", + "title": "Inside the mind of a fan 观赛心境", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/", + "filename": "151. P3 - Inside the mind of a fan 观赛心境.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-60": { + "examId": "p1-medium-60", + "dataKey": "p1-medium-60", + "script": "./p1-medium-60.js", + "title": "Sorry—who are you 脸盲症", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/", + "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-61": { + "examId": "p1-low-61", + "dataKey": "p1-low-61", + "script": "./p1-low-61.js", + "title": "Carnivorous plants 食虫植物", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/", + "filename": "153. P1 - Carnivorous plants 食虫植物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-62": { + "examId": "p2-low-62", + "dataKey": "p2-low-62", + "script": "./p2-low-62.js", + "title": "The purpose of facial expressions 面部表情", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/", + "filename": "154. P2 - The purpose of facial expressions 面部表情.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-63": { + "examId": "p1-medium-63", + "dataKey": "p1-medium-63", + "script": "./p1-medium-63.js", + "title": "A Brief History of Humans and Food 人类食物的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/", + "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-64": { + "examId": "p2-low-64", + "dataKey": "p2-low-64", + "script": "./p2-low-64.js", + "title": "New filter promises clean water for millions 新型泥土净水器", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/", + "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-65": { + "examId": "p2-low-65", + "dataKey": "p2-low-65", + "script": "./p2-low-65.js", + "title": "Boring Buildings 无聊建筑", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/", + "filename": "157. P2 - Boring Buildings 无聊建筑.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-66": { + "examId": "p3-medium-66", + "dataKey": "p3-medium-66", + "script": "./p3-medium-66.js", + "title": "Mercator - The Map Maker 地理制图师", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/", + "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-67": { + "examId": "p1-low-67", + "dataKey": "p1-low-67", + "script": "./p1-low-67.js", + "title": "Scented Plants 植物的味道", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/", + "filename": "159. P1 - Scented Plants 植物的味道.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-68": { + "examId": "p1-low-68", + "dataKey": "p1-low-68", + "script": "./p1-low-68.js", + "title": "The Clipper Races 帆船竞速", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", + "filename": "16. P1 - The Clipper Races 帆船竞速.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-69": { + "examId": "p1-low-69", + "dataKey": "p1-low-69", + "script": "./p1-low-69.js", + "title": "An important language development 楔形文字", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/", + "filename": "160. P1 - An important language development 楔形文字.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-70": { + "examId": "p1-low-70", + "dataKey": "p1-low-70", + "script": "./p1-low-70.js", + "title": "Fluorescence Deep sea discovery深海发光生物研究", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", + "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-71": { + "examId": "p3-low-71", + "dataKey": "p3-low-71", + "script": "./p3-low-71.js", + "title": "Sea Change for Salinity 土地盐碱化", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/", + "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-72": { + "examId": "p1-low-72", + "dataKey": "p1-low-72", + "script": "./p1-low-72.js", + "title": "How to find your way out of a food desert 城市食物荒漠", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/", + "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-73": { + "examId": "p2-low-73", + "dataKey": "p2-low-73", + "script": "./p2-low-73.js", + "title": "The Power of Smell 嗅觉的力量", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/", + "filename": "164. P2 - The Power of Smell 嗅觉的力量.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-74": { + "examId": "p3-low-74", + "dataKey": "p3-low-74", + "script": "./p3-low-74.js", + "title": "The Placebo Effect5 安慰剂效应", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/", + "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-75": { + "examId": "p2-low-75", + "dataKey": "p2-low-75", + "script": "./p2-low-75.js", + "title": "Lean Production Innovation 精益生产", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", + "filename": "166. P2 - Lean Production Innovation 精益生产.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-76": { + "examId": "p3-low-76", + "dataKey": "p3-low-76", + "script": "./p3-low-76.js", + "title": "Sign, Baby, Sign! 美国手语", + "category": "P3", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/", + "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-77": { + "examId": "p2-low-77", + "dataKey": "p2-low-77", + "script": "./p2-low-77.js", + "title": "Mammoth Kill 猛犸象的灭绝", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", + "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-78": { + "examId": "p3-low-78", + "dataKey": "p3-low-78", + "script": "./p3-low-78.js", + "title": "The Costs of Brand Loyalty 品牌忠诚的代价", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/", + "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-79": { + "examId": "p1-high-79", + "dataKey": "p1-high-79", + "script": "./p1-high-79.js", + "title": "The Development of The Silk Industry 丝绸产业发展", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/", + "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-80": { + "examId": "p1-low-80", + "dataKey": "p1-low-80", + "script": "./p1-low-80.js", + "title": "The unsung sense 被低估的嗅觉", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", + "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-81": { + "examId": "p1-low-81", + "dataKey": "p1-low-81", + "script": "./p1-low-81.js", + "title": "Salt 盐的历史", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/", + "filename": "171. P1 - Salt 盐的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-82": { + "examId": "p1-high-82", + "dataKey": "p1-high-82", + "script": "./p1-high-82.js", + "title": "Think Small 微观科学", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", + "filename": "172. P1 - Think Small 微观科学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-83": { + "examId": "p3-low-83", + "dataKey": "p3-low-83", + "script": "./p3-low-83.js", + "title": "1018纸笔 Looking for inspiration 寻找灵感", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/", + "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-84": { + "examId": "p1-low-84", + "dataKey": "p1-low-84", + "script": "./p1-low-84.js", + "title": "Why good ideas fail TF公司", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", + "filename": "174. P1 - Why good ideas fail TF公司.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-85": { + "examId": "p3-low-85", + "dataKey": "p3-low-85", + "script": "./p3-low-85.js", + "title": "Music soothes and awes 音乐疗愈", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/", + "filename": "175. P3 - Music soothes and awes 音乐疗愈.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-86": { + "examId": "p2-medium-86", + "dataKey": "p2-medium-86", + "script": "./p2-medium-86.js", + "title": "Urban Regeneration 柏林公园改造", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", + "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-87": { + "examId": "p2-low-87", + "dataKey": "p2-low-87", + "script": "./p2-low-87.js", + "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/", + "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-88": { + "examId": "p3-low-88", + "dataKey": "p3-low-88", + "script": "./p3-low-88.js", + "title": "1025纸笔Translating a key to international understanding 翻译的艺术", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/", + "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-89": { + "examId": "p3-high-89", + "dataKey": "p3-high-89", + "script": "./p3-high-89.js", + "title": "Looking at daily life in ancient Rome 古罗马的日常", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/", + "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-90": { + "examId": "p1-high-90", + "dataKey": "p1-high-90", + "script": "./p1-high-90.js", + "title": "The History of Tea 茶叶的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/", + "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-91": { + "examId": "p2-high-91", + "dataKey": "p2-high-91", + "script": "./p2-high-91.js", + "title": "Australia’s camouflaged creatures 澳洲伪装生物", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", + "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-92": { + "examId": "p1-high-92", + "dataKey": "p1-high-92", + "script": "./p1-high-92.js", + "title": "Dust and the American West 美国西部尘埃", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", + "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-93": { + "examId": "p2-medium-93", + "dataKey": "p2-medium-93", + "script": "./p2-medium-93.js", + "title": "Antarctic research 南极考察", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", + "filename": "182. P2 - Antarctic research 南极考察.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-94": { + "examId": "p2-low-94", + "dataKey": "p2-low-94", + "script": "./p2-low-94.js", + "title": "The importance of being playful 玩耍的重要性", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/", + "filename": "183. P2 - The importance of being playful 玩耍的重要性.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-95": { + "examId": "p3-low-95", + "dataKey": "p3-low-95", + "script": "./p3-low-95.js", + "title": "The strange world of sight 奇异的视觉世界", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", + "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-96": { + "examId": "p2-low-96", + "dataKey": "p2-low-96", + "script": "./p2-low-96.js", + "title": "[Pretest] Why Do We Need Sleep 睡眠的目的", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/", + "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-97": { + "examId": "p3-low-97", + "dataKey": "p3-low-97", + "script": "./p3-low-97.js", + "title": "Saving languages 拯救濒危语言", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/", + "filename": "186. P3 - Saving languages 拯救濒危语言.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-98": { + "examId": "p3-low-98", + "dataKey": "p3-low-98", + "script": "./p3-low-98.js", + "title": "Petrol power an eco-revolution 交通的革命", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/", + "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-99": { + "examId": "p1-low-99", + "dataKey": "p1-low-99", + "script": "./p1-low-99.js", + "title": "The history of the bar code 条形码的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", + "filename": "188. P1 - The history of the bar code 条形码的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-100": { + "examId": "p3-low-100", + "dataKey": "p3-low-100", + "script": "./p3-low-100.js", + "title": "Mirror 镜子研究", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/", + "filename": "189. P3 - Mirror 镜子研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-101": { + "examId": "p1-high-101", + "dataKey": "p1-high-101", + "script": "./p1-high-101.js", + "title": "The Impact of the Potato 土豆的影响", + "category": "P1", + "frequency": "高频", + "difficultyScore": 1, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/", + "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-102": { + "examId": "p2-low-102", + "dataKey": "p2-low-102", + "script": "./p2-low-102.js", + "title": "The power of music 音乐的力量", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/", + "filename": "190. P2 - The power of music 音乐的力量.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-103": { + "examId": "p2-low-103", + "dataKey": "p2-low-103", + "script": "./p2-low-103.js", + "title": "The economic effect of climate 气候对经济的影响", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", + "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-104": { + "examId": "p2-low-104", + "dataKey": "p2-low-104", + "script": "./p2-low-104.js", + "title": "1115纸笔Should we stop eating meat 是否应该吃素", + "category": "P2", + "frequency": "low", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", + "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-105": { + "examId": "p1-high-105", + "dataKey": "p1-high-105", + "script": "./p1-high-105.js", + "title": "A survivor’s story 新西兰猫头鹰", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/", + "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-106": { + "examId": "p1-low-106", + "dataKey": "p1-low-106", + "script": "./p1-low-106.js", + "title": "The Importance of Business Cards 名片的重要性", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/", + "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-107": { + "examId": "p1-low-107", + "dataKey": "p1-low-107", + "script": "./p1-low-107.js", + "title": "The life of Beatrix Potter 彼得兔作家", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", + "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-108": { + "examId": "p1-low-108", + "dataKey": "p1-low-108", + "script": "./p1-low-108.js", + "title": "The nature of Yawning 打哈欠的本质", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/", + "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-109": { + "examId": "p1-low-109", + "dataKey": "p1-low-109", + "script": "./p1-low-109.js", + "title": "The Origin of Paper 造纸术起源", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", + "filename": "23. P1 - The Origin of Paper 造纸术起源.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-110": { + "examId": "p1-high-110", + "dataKey": "p1-high-110", + "script": "./p1-high-110.js", + "title": "The Pearls 珍珠", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/", + "filename": "24. P1 - The Pearls 珍珠【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-111": { + "examId": "p1-low-111", + "dataKey": "p1-low-111", + "script": "./p1-low-111.js", + "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/", + "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-112": { + "examId": "p1-low-112", + "dataKey": "p1-low-112", + "script": "./p1-low-112.js", + "title": "The Tuatara of New Zealand 新西兰蜥蜴", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/", + "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-113": { + "examId": "p1-low-113", + "dataKey": "p1-low-113", + "script": "./p1-low-113.js", + "title": "Thomas Young The last man who knew everything 托马斯·杨", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", + "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-114": { + "examId": "p1-low-114", + "dataKey": "p1-low-114", + "script": "./p1-low-114.js", + "title": "Triumph of the City 城市的胜利", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 1.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", + "filename": "28. P1 - Triumph of the City 城市的胜利.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-115": { + "examId": "p1-medium-115", + "dataKey": "p1-medium-115", + "script": "./p1-medium-115.js", + "title": "Tunnelling under the Thames", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", + "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-116": { + "examId": "p1-low-116", + "dataKey": "p1-low-116", + "script": "./p1-low-116.js", + "title": "Advertising Needs Attention 广告的吸引力", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/", + "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-117": { + "examId": "p1-medium-117", + "dataKey": "p1-medium-117", + "script": "./p1-medium-117.js", + "title": "What Lucy Taught Us 露西化石", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", + "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-118": { + "examId": "p1-high-118", + "dataKey": "p1-high-118", + "script": "./p1-high-118.js", + "title": "William Gilbert and Magnetism 电磁学之父", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/", + "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-119": { + "examId": "p1-medium-119", + "dataKey": "p1-medium-119", + "script": "./p1-medium-119.js", + "title": "Wood 新西兰木材产业", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", + "filename": "32. P1 - Wood 新西兰木材产业【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-120": { + "examId": "p2-high-120", + "dataKey": "p2-high-120", + "script": "./p2-high-120.js", + "title": "A new look for Talbot Park 奥克兰社区改造", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/", + "filename": "ai_studio_code (9).html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-121": { + "examId": "p2-medium-121", + "dataKey": "p2-medium-121", + "script": "./p2-medium-121.js", + "title": "A unique golden textile 蜘蛛丝", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/", + "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-122": { + "examId": "p2-low-122", + "dataKey": "p2-low-122", + "script": "./p2-low-122.js", + "title": "Biophilic Design 亲自然设计", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", + "filename": "35. P2 - Biophilic Design 亲自然设计.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-123": { + "examId": "p2-high-123", + "dataKey": "p2-high-123", + "script": "./p2-high-123.js", + "title": "Bird Migration 鸟类迁徙", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/", + "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-124": { + "examId": "p2-high-124", + "dataKey": "p2-high-124", + "script": "./p2-high-124.js", + "title": "Corporate Social Responsibility 企业社会责任", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", + "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-125": { + "examId": "p2-low-125", + "dataKey": "p2-low-125", + "script": "./p2-low-125.js", + "title": "Egypt’s ancient boat-builders 古埃及造船", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/", + "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-126": { + "examId": "p2-medium-126", + "dataKey": "p2-medium-126", + "script": "./p2-medium-126.js", + "title": "How are deserts formed 沙漠成因", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/", + "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-127": { + "examId": "p1-low-127", + "dataKey": "p1-low-127", + "script": "./p1-low-127.js", + "title": "Ambergris 龙涎香", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", + "filename": "4. P1 - Ambergris 龙涎香.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-128": { + "examId": "p2-high-128", + "dataKey": "p2-high-128", + "script": "./p2-high-128.js", + "title": "How Well Do We Concentrate_ 多任务处理", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/", + "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-129": { + "examId": "p2-medium-129", + "dataKey": "p2-medium-129", + "script": "./p2-medium-129.js", + "title": "Intelligent behaviour in birds 鸟类智慧行为", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", + "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-130": { + "examId": "p2-high-130", + "dataKey": "p2-high-130", + "script": "./p2-high-130.js", + "title": "Investment in shares versus investment in other assets 回报数据分析", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", + "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-131": { + "examId": "p2-high-131", + "dataKey": "p2-high-131", + "script": "./p2-high-131.js", + "title": "Learning from the Romans 罗马混凝土", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/", + "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-132": { + "examId": "p2-low-132", + "dataKey": "p2-low-132", + "script": "./p2-low-132.js", + "title": "Orientation of Birds 鸟类的定位能力", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/", + "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-133": { + "examId": "p2-high-133", + "dataKey": "p2-high-133", + "script": "./p2-high-133.js", + "title": "Playing soccer 街头足球", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/", + "filename": "45. P2 - Playing soccer 街头足球【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-134": { + "examId": "p2-high-134", + "dataKey": "p2-high-134", + "script": "./p2-high-134.js", + "title": "Roller coaster 过山车", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/", + "filename": "46. P2 - Roller coaster 过山车【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-135": { + "examId": "p2-low-135", + "dataKey": "p2-low-135", + "script": "./p2-low-135.js", + "title": "Skyscraper Farming 摩天大楼种植", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", + "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-136": { + "examId": "p2-high-136", + "dataKey": "p2-high-136", + "script": "./p2-high-136.js", + "title": "Solving the problem of waste disposal 垃圾处理", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/", + "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-137": { + "examId": "p2-high-137", + "dataKey": "p2-high-137", + "script": "./p2-high-137.js", + "title": "Surviving city life 动物适应城市", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", + "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-138": { + "examId": "p1-low-138", + "dataKey": "p1-low-138", + "script": "./p1-low-138.js", + "title": "Australian artist Margaret Preston 澳大利亚艺术家", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/", + "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-139": { + "examId": "p2-high-139", + "dataKey": "p2-high-139", + "script": "./p2-high-139.js", + "title": "The conquest of malaria in Italy 意大利疟疾防治", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", + "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-140": { + "examId": "p2-low-140", + "dataKey": "p2-low-140", + "script": "./p2-low-140.js", + "title": "The dingo debate 澳洲野犬", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/", + "filename": "51. P2 - The dingo debate 澳洲野犬.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-141": { + "examId": "p2-high-141", + "dataKey": "p2-high-141", + "script": "./p2-high-141.js", + "title": "The fascinating world of attine ants 切叶蚁", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/", + "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-142": { + "examId": "p2-low-142", + "dataKey": "p2-low-142", + "script": "./p2-low-142.js", + "title": "The fashion industry 时尚产业", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", + "filename": "53. P2 - The fashion industry 时尚产业.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-143": { + "examId": "p2-low-143", + "dataKey": "p2-low-143", + "script": "./p2-low-143.js", + "title": "The impact of invasive species 入侵物种的影响", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", + "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-144": { + "examId": "p2-medium-144", + "dataKey": "p2-medium-144", + "script": "./p2-medium-144.js", + "title": "The plan to bring an asteroid to Earth 捕获小行星", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/", + "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-145": { + "examId": "p2-high-145", + "dataKey": "p2-high-145", + "script": "./p2-high-145.js", + "title": "The return of monkey life 猴群回归", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", + "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-146": { + "examId": "p2-medium-146", + "dataKey": "p2-medium-146", + "script": "./p2-medium-146.js", + "title": "The Tasmanian Tiger 袋狼", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/", + "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-147": { + "examId": "p2-low-147", + "dataKey": "p2-low-147", + "script": "./p2-low-147.js", + "title": "Who wrote Shakespeare's plays 莎士比亚", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", + "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-148": { + "examId": "p2-low-148", + "dataKey": "p2-low-148", + "script": "./p2-low-148.js", + "title": "Why do we need the arts_ 艺术的意义", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/", + "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-149": { + "examId": "p1-low-149", + "dataKey": "p1-low-149", + "script": "./p1-low-149.js", + "title": "Categorizing societies 社会分类", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/", + "filename": "6. P1 - Categorizing societies 社会分类html.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-150": { + "examId": "p3-high-150", + "dataKey": "p3-high-150", + "script": "./p3-high-150.js", + "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/", + "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-151": { + "examId": "p3-low-151", + "dataKey": "p3-low-151", + "script": "./p3-low-151.js", + "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/", + "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-152": { + "examId": "p3-medium-152", + "dataKey": "p3-medium-152", + "script": "./p3-medium-152.js", + "title": "Charles Darwin and Evolutionary Psychology 进化心理学", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", + "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-153": { + "examId": "p3-low-153", + "dataKey": "p3-low-153", + "script": "./p3-low-153.js", + "title": "Crossing the Threshold 奥克兰美术馆", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/", + "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-154": { + "examId": "p3-medium-154", + "dataKey": "p3-medium-154", + "script": "./p3-medium-154.js", + "title": "Decisions, Decisions 决策之间", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/", + "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-155": { + "examId": "p3-medium-155", + "dataKey": "p3-medium-155", + "script": "./p3-medium-155.js", + "title": "Does class size matter_ 课堂规模", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", + "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-156": { + "examId": "p3-high-156", + "dataKey": "p3-high-156", + "script": "./p3-high-156.js", + "title": "Elephant Communication 大象交流", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/", + "filename": "66. P3 - Elephant Communication 大象交流【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-157": { + "examId": "p3-high-157", + "dataKey": "p3-high-157", + "script": "./p3-high-157.js", + "title": "Flower Power 鲜花的力量(花之力)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", + "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf", + "sourceKind": "generated-reading" + }, + "p3-low-158": { + "examId": "p3-low-158", + "dataKey": "p3-low-158", + "script": "./p3-low-158.js", + "title": "Game theory 博弈论", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/", + "filename": "68. P3 - Game theory 博弈论.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-159": { + "examId": "p3-high-159", + "dataKey": "p3-high-159", + "script": "./p3-high-159.js", + "title": "Grimm’s Fairy Tales 格林童话", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/", + "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-160": { + "examId": "p1-low-160", + "dataKey": "p1-low-160", + "script": "./p1-low-160.js", + "title": "Chili peppers 辣椒的历史", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/", + "filename": "7. P1 - Chili peppers 辣椒的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-161": { + "examId": "p3-high-161", + "dataKey": "p3-high-161", + "script": "./p3-high-161.js", + "title": "Insect-inspired robots 昆虫机器人", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/", + "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-162": { + "examId": "p3-medium-162", + "dataKey": "p3-medium-162", + "script": "./p3-medium-162.js", + "title": "Jean Piaget (1896–1980) 让·皮亚杰", + "category": "P3", + "frequency": "low", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", + "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-163": { + "examId": "p3-low-163", + "dataKey": "p3-low-163", + "script": "./p3-low-163.js", + "title": "Keeping the Fun in Funfairs 游乐场设计科学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/", + "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-164": { + "examId": "p3-high-164", + "dataKey": "p3-high-164", + "script": "./p3-high-164.js", + "title": "Language Strategy in Multinational Companies 跨国公司语言策略", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/", + "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-165": { + "examId": "p3-low-165", + "dataKey": "p3-low-165", + "script": "./p3-low-165.js", + "title": "Let’s teach them how to teach 教他们如何教学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/", + "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-166": { + "examId": "p3-low-166", + "dataKey": "p3-low-166", + "script": "./p3-low-166.js", + "title": "Life on Mars_ 火星地球化改造", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", + "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-167": { + "examId": "p3-high-167", + "dataKey": "p3-high-167", + "script": "./p3-high-167.js", + "title": "Living dunes 流动沙丘", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/", + "filename": "76. P3 - Living dunes 流动沙丘【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-168": { + "examId": "p3-medium-168", + "dataKey": "p3-medium-168", + "script": "./p3-medium-168.js", + "title": "Marketing and the information age 信息时代营销", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", + "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-169": { + "examId": "p3-medium-169", + "dataKey": "p3-medium-169", + "script": "./p3-medium-169.js", + "title": "(无题目) Music Language We All Speak 音乐语言", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", + "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-170": { + "examId": "p3-high-170", + "dataKey": "p3-high-170", + "script": "./p3-high-170.js", + "title": "Pacific Navigation and Voyaging 太平洋航海", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/", + "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-171": { + "examId": "p1-high-171", + "dataKey": "p1-high-171", + "script": "./p1-high-171.js", + "title": "Fishbourne Roman Palace 罗马宫殿", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/", + "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-172": { + "examId": "p3-low-172", + "dataKey": "p3-low-172", + "script": "./p3-low-172.js", + "title": "Rebranding art museums 博物馆品牌重塑", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/", + "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-173": { + "examId": "p3-high-173", + "dataKey": "p3-high-173", + "script": "./p3-high-173.js", + "title": "Robert Louis Stevenson", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", + "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-174": { + "examId": "p3-high-174", + "dataKey": "p3-high-174", + "script": "./p3-high-174.js", + "title": "Some views on the use of headphones 耳机使用", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/", + "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-175": { + "examId": "p3-low-175", + "dataKey": "p3-low-175", + "script": "./p3-low-175.js", + "title": "Termite Mounds 白蚁丘", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/", + "filename": "83. P3 - Termite Mounds 白蚁丘.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-176": { + "examId": "p3-medium-176", + "dataKey": "p3-medium-176", + "script": "./p3-medium-176.js", + "title": "The Analysis of Fear 猴子恐惧实验", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", + "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-177": { + "examId": "p3-medium-177", + "dataKey": "p3-medium-177", + "script": "./p3-medium-177.js", + "title": "The Art of Deception 欺骗的艺术", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/", + "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-178": { + "examId": "p3-high-178", + "dataKey": "p3-high-178", + "script": "./p3-high-178.js", + "title": "The benefits of learning an instrument 学乐器的好处", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/", + "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-179": { + "examId": "p3-medium-179", + "dataKey": "p3-medium-179", + "script": "./p3-medium-179.js", + "title": "The Exploration of Mars 火星探索", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/", + "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-180": { + "examId": "p3-high-180", + "dataKey": "p3-high-180", + "script": "./p3-high-180.js", + "title": "The fluoridation controversy 氟化水争议", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/", + "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-181": { + "examId": "p3-high-181", + "dataKey": "p3-high-181", + "script": "./p3-high-181.js", + "title": "The Fruit Book 果实之书", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/", + "filename": "89. P3 - The Fruit Book 果实之书【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-182": { + "examId": "p1-medium-182", + "dataKey": "p1-medium-182", + "script": "./p1-medium-182.js", + "title": "Listening to the Ocean 海洋探测", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", + "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-183": { + "examId": "p3-medium-183", + "dataKey": "p3-medium-183", + "script": "./p3-medium-183.js", + "title": "The hazards of multitasking 多任务处理", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", + "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-184": { + "examId": "p3-high-184", + "dataKey": "p3-high-184", + "script": "./p3-high-184.js", + "title": "The New Zealand writer Margaret Mahy 新西兰女作家", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/", + "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-185": { + "examId": "p3-medium-185", + "dataKey": "p3-medium-185", + "script": "./p3-medium-185.js", + "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/", + "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-186": { + "examId": "p3-low-186", + "dataKey": "p3-low-186", + "script": "./p3-low-186.js", + "title": "The Robbers Cave Study (山洞)群体行为实验", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", + "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-187": { + "examId": "p3-low-187", + "dataKey": "p3-low-187", + "script": "./p3-low-187.js", + "title": "The science of sleep 睡眠的科学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/", + "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-188": { + "examId": "p3-medium-188", + "dataKey": "p3-medium-188", + "script": "./p3-medium-188.js", + "title": "The Significant Role of Mother Tongue in Education 母语教育", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/", + "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-189": { + "examId": "p3-high-189", + "dataKey": "p3-high-189", + "script": "./p3-high-189.js", + "title": "The tuatara – past and future 新西兰蜥蜴", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/", + "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-190": { + "examId": "p3-low-190", + "dataKey": "p3-low-190", + "script": "./p3-low-190.js", + "title": "The value of literary prizes 文学奖项的价值", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/", + "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-191": { + "examId": "p3-medium-191", + "dataKey": "p3-medium-191", + "script": "./p3-medium-191.js", + "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/", + "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-192": { + "examId": "p3-high-192", + "dataKey": "p3-high-192", + "script": "./p3-high-192.js", + "title": "Voynich Manuscript 伏尼契手稿", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/", + "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-200": { + "examId": "p1-high-200", + "dataKey": "p1-high-200", + "script": "./p1-high-200.js", + "title": "Australia’s Airborne Dentists 澳洲飞行牙医", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "三月/1.P1 高频/", + "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-211": { + "examId": "p1-high-211", + "dataKey": "p1-high-211", + "script": "./p1-high-211.js", + "title": "Ahead of its time 新西兰头骨", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "三月/1.P1 高频/", + "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-216": { + "examId": "p1-high-216", + "dataKey": "p1-high-216", + "script": "./p1-high-216.js", + "title": "Australia’s cane toad problem 澳洲蟾蜍", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "三月/1.P1 高频/", + "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-194": { + "examId": "p1-high-194", + "dataKey": "p1-high-194", + "script": "./p1-high-194.js", + "title": "The history of the British wool industry 英国羊毛产业的历史", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "三月/2.P1 次高频/", + "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-222": { + "examId": "p2-low-222", + "dataKey": "p2-low-222", + "script": "./p2-low-222.js", + "title": "Ideal Homes 理想居所", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "三月/", + "filename": "222. P2 - Ideal Homes 理想居所.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-223": { + "examId": "p1-low-223", + "dataKey": "p1-low-223", + "script": "./p1-low-223.js", + "title": "Effect and Cause 湖泊海啸研究", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "三月/", + "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-201": { + "examId": "p2-high-201", + "dataKey": "p2-high-201", + "script": "./p2-high-201.js", + "title": "Multi-tasking and the brain 大脑与多任务处理", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "三月/3.P2 高频/", + "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-217": { + "examId": "p2-medium-217", + "dataKey": "p2-medium-217", + "script": "./p2-medium-217.js", + "title": "A mechanical friend for children 孩子的机器人朋友", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/3.P2 高频/", + "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-192": { + "examId": "p2-high-192", + "dataKey": "p2-high-192", + "script": "./p2-high-192.js", + "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", + "category": "P2", + "frequency": "low", + "difficultyScore": 3.5, + "path": "三月/4.P2 次高频/", + "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-209": { + "examId": "p2-medium-209", + "dataKey": "p2-medium-209", + "script": "./p2-medium-209.js", + "title": "Decision Fatigue 决策疲劳", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-213": { + "examId": "p2-medium-213", + "dataKey": "p2-medium-213", + "script": "./p2-medium-213.js", + "title": "Growing more for less 卫星农业", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "三月/4.P2 次高频/", + "filename": "213. P2 - Growing more for less 卫星农业【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-051": { + "examId": "p2-low-051", + "dataKey": "p2-low-051", + "script": "./p2-low-051.js", + "title": "The dingo debate 澳洲野犬_澳洲野狗", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-058": { + "examId": "p2-medium-058", + "dataKey": "p2-medium-058", + "script": "./p2-medium-058.js", + "title": "Who wrote Shakespeare's plays 莎士比亚", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-204": { + "examId": "p3-high-204", + "dataKey": "p3-high-204", + "script": "./p3-high-204.js", + "title": "When people are ‘deaf’ to music 失乐症", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-206": { + "examId": "p3-high-206", + "dataKey": "p3-high-206", + "script": "./p3-high-206.js", + "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-212": { + "examId": "p3-high-212", + "dataKey": "p3-high-212", + "script": "./p3-high-212.js", + "title": "Children’s literature studies today 儿童文学", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-218": { + "examId": "p3-high-218", + "dataKey": "p3-high-218", + "script": "./p3-high-218.js", + "title": "The Causes of Linguistic Change 语音的演变", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-219": { + "examId": "p3-low-219", + "dataKey": "p3-low-219", + "script": "./p3-low-219.js", + "title": "The origin of language 语言的起源", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "219. P3 - The origin of language 语言的起源.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-999": { + "examId": "p3-low-999", + "dataKey": "p3-low-999", + "script": "./p3-low-999.js", + "title": "Risk taking", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "P3 - Risk taking.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-medium-197": { + "examId": "p3-medium-197", + "dataKey": "p3-medium-197", + "script": "./p3-medium-197.js", + "title": "Australia’s Megafauna Controversy 巨兽灭绝", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/6.P3 次高频/", + "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-198": { + "examId": "p3-low-198", + "dataKey": "p3-low-198", + "script": "./p3-low-198.js", + "title": "Child’s Play in Medieval England 中世纪的游戏", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "三月/6.P3 次高频/", + "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-078": { + "examId": "p3-low-078", + "dataKey": "p3-low-078", + "script": "./p3-low-078.js", + "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "三月/6.P3 次高频/", + "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-227": { + "examId": "p1-high-227", + "dataKey": "p1-high-227", + "script": "./p1-high-227.js", + "title": "The Whale Goes to Court 鲸鱼油", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "ReadingPractice/PDF/", + "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-225": { + "examId": "p2-high-225", + "dataKey": "p2-high-225", + "script": "./p2-high-225.js", + "title": "The problem of graffiti 涂鸦之困", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "ReadingPractice/PDF/", + "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-228": { + "examId": "p3-high-228", + "dataKey": "p3-high-228", + "script": "./p3-high-228.js", + "title": "On art and artists 艺术与艺术家", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "ReadingPractice/PDF/", + "filename": "228. P3 - On art and artists 艺术与艺术家.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-229": { + "examId": "p1-high-229", + "dataKey": "p1-high-229", + "script": "./p1-high-229.js", + "title": "New Understanding of Giraffes in the Wild 野生长颈鹿", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "ReadingPractice/PDF/", + "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-230": { + "examId": "p1-high-230", + "dataKey": "p1-high-230", + "script": "./p1-high-230.js", + "title": "The History of the Pencil 铅笔的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 1.5, + "path": "ReadingPractice/PDF/", + "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-231": { + "examId": "p1-high-231", + "dataKey": "p1-high-231", + "script": "./p1-high-231.js", + "title": "The History of the Pencil 铅笔的历史(流程图版)", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "ReadingPractice/PDF/", + "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", + "sourceKind": "generated-reading" + }, + "p2-high-232": { + "examId": "p2-high-232", + "dataKey": "p2-high-232", + "script": "./p2-high-232.js", + "title": "The origin and development of applause 掌声的历史", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "ReadingPractice/PDF/", + "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-233": { + "examId": "p2-high-233", + "dataKey": "p2-high-233", + "script": "./p2-high-233.js", + "title": "Why don’t we sleep 失眠的原因", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "ReadingPractice/PDF/", + "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-234": { + "examId": "p2-high-234", + "dataKey": "p2-high-234", + "script": "./p2-high-234.js", + "title": "How do plants talk to each other 植物交流", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "ReadingPractice/PDF/", + "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-221": { + "examId": "p3-high-221", + "dataKey": "p3-high-221", + "script": "./p3-high-221.js", + "title": "The Animal Connection 动物联结", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "221. P3 - The Animal Connection 动物联结.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-235": { + "examId": "p2-high-235", + "dataKey": "p2-high-235", + "script": "./p2-high-235.js", + "title": "The return of the black-footed ferret 黑足鼬", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-236": { + "examId": "p2-high-236", + "dataKey": "p2-high-236", + "script": "./p2-high-236.js", + "title": "War of the Plants 植物的战争", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "", + "filename": "", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-high-229": { + "examId": "p3-high-229", + "dataKey": "p3-high-229", + "script": "./p3-high-229.js", + "title": "All in the family 兄弟姐妹的影响", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-239": { + "examId": "p2-high-239", + "dataKey": "p2-high-239", + "script": "./p2-high-239.js", + "title": "Nanotechnology: the science of the very small 纳米科技", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-240": { + "examId": "p2-low-240", + "dataKey": "p2-low-240", + "script": "./p2-low-240.js", + "title": "Coins - the first form of money 硬币起源", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "assets/generated/reading-exams/", + "filename": "reading-practice-unified.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p1-high-240": { + "examId": "p1-high-240", + "dataKey": "p1-high-240", + "script": "./p1-high-240.js", + "title": "The Origins of Weather Forecasting 天气预报", + "category": "P1", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-242": { + "examId": "p2-low-242", + "dataKey": "p2-low-242", + "script": "./p2-low-242.js", + "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-240": { + "examId": "p3-low-240", + "dataKey": "p3-low-240", + "script": "./p3-low-240.js", + "title": "How a prehistoric predator took to the skies 翼龙飞行", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - How a prehistoric predator took to the skies.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-241": { + "examId": "p3-medium-241", + "dataKey": "p3-medium-241", + "script": "./p3-medium-241.js", + "title": "Who looks after the children in today's Britain? 育儿分工", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - Who looks after the children in today's Britain.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-243": { + "examId": "p2-medium-243", + "dataKey": "p2-medium-243", + "script": "./p2-medium-243.js", + "title": "The internal body clock", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "", + "filename": "", + "hasHtml": false, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-medium-244": { + "examId": "p3-medium-244", + "dataKey": "p3-medium-244", + "script": "./p3-medium-244.js", + "title": "Look who was talking", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "", + "filename": "", + "hasHtml": false, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + } + }; function clonePathRoot() { From fbce0212a085af975332352fc22038583e5334ff Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:33:29 +0800 Subject: [PATCH 08/18] feat(data): replace legacy persistence with AppData v2 --- js/components/DataIntegrityManager.js | 637 ---- js/components/dataManagementPanel.js | 1106 ------ js/components/goalSettingsPanel.js | 253 -- js/core/backupAPI.js | 392 --- js/core/goalManager.js | 363 -- js/core/practiceRecordAPI.js | 883 ----- js/core/practiceStore.js | 53 - js/core/resourceCore.js | 71 +- js/core/scoreStorage.js | 1876 ----------- js/core/siteDataReset.js | 768 +++++ js/core/storageProviderRegistry.js | 83 - js/core/vocabStore.js | 292 +- js/data/dataSources/storageDataSource.js | 137 - js/data/index.js | 241 -- js/data/practiceRecordSource.js | 199 ++ js/data/repositories/backupRepository.js | 142 - js/data/repositories/baseRepository.js | 163 - .../repositories/dataRepositoryRegistry.js | 68 - js/data/repositories/metaRepository.js | 70 - js/data/repositories/practiceRepository.js | 206 -- js/data/repositories/settingsRepository.js | 81 - js/data/v2/appData.js | 2656 +++++++++++++++ js/data/v2/dataCatalog.js | 230 ++ js/data/v2/dataKernel.js | 888 +++++ js/patches/runtime-fixes.js | 109 - js/presentation/developerTeamModal.js | 58 - js/utils/dataBackupManager.js | 900 ----- js/utils/safeObjectLiteralParser.js | 300 ++ js/utils/simpleStorageWrapper.js | 183 - js/utils/stateSerializer.js | 175 - js/utils/storage.js | 2993 ----------------- js/utils/vocabDataIO.js | 20 +- 32 files changed, 5163 insertions(+), 11433 deletions(-) delete mode 100644 js/components/DataIntegrityManager.js delete mode 100644 js/components/dataManagementPanel.js delete mode 100644 js/components/goalSettingsPanel.js delete mode 100644 js/core/backupAPI.js delete mode 100644 js/core/goalManager.js delete mode 100644 js/core/practiceRecordAPI.js delete mode 100644 js/core/practiceStore.js delete mode 100644 js/core/scoreStorage.js create mode 100644 js/core/siteDataReset.js delete mode 100644 js/core/storageProviderRegistry.js delete mode 100644 js/data/dataSources/storageDataSource.js delete mode 100644 js/data/index.js create mode 100644 js/data/practiceRecordSource.js delete mode 100644 js/data/repositories/backupRepository.js delete mode 100644 js/data/repositories/baseRepository.js delete mode 100644 js/data/repositories/dataRepositoryRegistry.js delete mode 100644 js/data/repositories/metaRepository.js delete mode 100644 js/data/repositories/practiceRepository.js delete mode 100644 js/data/repositories/settingsRepository.js create mode 100644 js/data/v2/appData.js create mode 100644 js/data/v2/dataCatalog.js create mode 100644 js/data/v2/dataKernel.js delete mode 100644 js/patches/runtime-fixes.js delete mode 100644 js/presentation/developerTeamModal.js delete mode 100644 js/utils/dataBackupManager.js create mode 100644 js/utils/safeObjectLiteralParser.js delete mode 100644 js/utils/simpleStorageWrapper.js delete mode 100644 js/utils/stateSerializer.js delete mode 100644 js/utils/storage.js diff --git a/js/components/DataIntegrityManager.js b/js/components/DataIntegrityManager.js deleted file mode 100644 index 949d142c..00000000 --- a/js/components/DataIntegrityManager.js +++ /dev/null @@ -1,637 +0,0 @@ -/** - * 数据完整性管理器 (仓库驱动版) - * 负责数据备份、验证、修复和导入导出功能 - * 基于统一的数据仓库接口执行原子操作 - */ -class DataIntegrityManager { - constructor(options = {}) { - this.backupInterval = 600000; // 10分钟自动备份 - this.maxBackups = 5; // 最多保留5个备份(减少占用) - this.dataVersion = '0.6.2-fix'; - this.backupTimer = null; - this.validationRules = new Map(); - this.repositories = null; - this.consistencyReport = null; - this.isInitialized = false; - this.registry = options.registry || window.StorageProviderRegistry || null; - this._unsubscribe = null; - - this.registerDefaultValidationRules(); - this.connectToProviders(); - - console.log('[DataIntegrityManager] 数据完整性管理器已创建'); - } - - connectToProviders() { - const registry = this.registry; - if (registry && typeof registry.onProvidersReady === 'function') { - this._unsubscribe = registry.onProvidersReady(({ repositories }) => { - this.attachRepositories(repositories); - }); - const current = registry.getCurrentProviders && registry.getCurrentProviders(); - if (current && current.repositories) { - this.attachRepositories(current.repositories); - } - return; - } - - if (window.dataRepositories) { - this.attachRepositories(window.dataRepositories); - return; - } - - console.warn('[DataIntegrityManager] 未检测到数据仓库注册表,等待外部注入'); - } - - async attachRepositories(repositories) { - if (!repositories) { - return; - } - if (this.repositories === repositories && this.isInitialized) { - return; - } - - this.repositories = repositories; - console.log('[DataIntegrityManager] 已绑定数据仓库接口'); - - try { - await this.initializeWithRepositories(); - } catch (error) { - console.error('[DataIntegrityManager] 初始化失败:', error); - this.startAutoBackup(); - this.isInitialized = true; - } - } - - async initializeWithRepositories() { - try { - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - - try { - this.consistencyReport = await this.repositories.runConsistencyChecks(); - console.log('[DataIntegrityManager] 初始一致性检查完成', this.consistencyReport); - } catch (reportError) { - console.warn('[DataIntegrityManager] 初始一致性检查失败:', reportError); - } - - this.startAutoBackup(); - try { await this.cleanupOldBackups(); } catch (_) {} - - this.isInitialized = true; - console.log('[DataIntegrityManager] 数据完整性管理器已初始化'); - } catch (error) { - throw error; - } - } - - _ensureInitialized() { - if (!this.isInitialized) { - console.warn('[DataIntegrityManager] 尚未完全初始化,使用降级模式'); - } - } - - async cleanupOldBackups() { - try { - if (!this.repositories) return; - const backups = await this.repositories.backups.list(); - if (backups.length <= this.maxBackups) return; - await this.repositories.backups.prune(this.maxBackups); - console.log('[DataIntegrityManager] 已执行备份裁剪'); - } catch (error) { - console.error('[DataIntegrityManager] 清理旧备份失败:', error); - } - } - - async createBackup(providedData, type = 'manual') { - let data = null; - try { - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - data = providedData || await this.getCriticalData(); - if (Object.keys(data).length === 0) { - throw new Error('无数据可备份'); - } - - // 统一经 BackupAPI(内部仍落 BackupRepository),保证 schema 与裁剪一致 - if (window.BackupAPI && typeof window.BackupAPI.create === 'function') { - const backupId = await window.BackupAPI.create({ - type, - data, - version: this.dataVersion - }); - const backupObj = await window.BackupAPI.getById(backupId); - console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${backupId}`); - return backupObj || { id: backupId, type, data, version: this.dataVersion }; - } - - const id = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const timestamp = new Date().toISOString(); - const backupObj = { - id, - timestamp, - data, - version: this.dataVersion, - type, - size: JSON.stringify(data).length - }; - await this.repositories.backups.add(backupObj); - console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${id}`); - return backupObj; - } catch (error) { - console.error('[DataIntegrityManager] 创建备份失败:', error); - if (error.name === 'QuotaExceededError' && data) { - this.exportDataAsFallback(data); - } - throw error; - } - } - - exportDataAsFallback(exportData) { - try { - const exportObj = { - exportDate: new Date().toISOString(), - version: this.dataVersion, - data: exportData, - note: 'Storage quota exceeded - manual backup' - }; - const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `ielts-data-backup-quota-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - console.log('[DataIntegrityManager] 配额溢出备份已下载'); - } catch (fallbackError) { - console.error('[DataIntegrityManager] fallback 导出失败:', fallbackError); - } - } - - registerDefaultValidationRules() { - this.validationRules.set('practice_records', { - required: ['id', 'startTime'], - types: { - id: 'string', - startTime: 'string', - endTime: 'string', - date: 'string', - duration: 'number', - examId: 'string', - examTitle: 'string', - scoreInfo: 'object' - }, - validators: { - startTime: (value) => !isNaN(new Date(value).getTime()), - date: (value) => !value || !isNaN(new Date(value).getTime()), - endTime: (value) => !value || !isNaN(new Date(value).getTime()), - duration: (value) => typeof value === 'number' && value >= 0, - id: (value) => typeof value === 'string' && value.length > 0 - } - }); - - this.validationRules.set('system_settings', { - types: { - theme: 'string', - language: 'string', - autoSave: 'boolean', - notifications: 'boolean' - } - }); - } - - startAutoBackup() { - if (this.backupTimer) { - clearInterval(this.backupTimer); - } - this.backupTimer = setInterval(() => { - this.performAutoBackup(); - }, this.backupInterval); - console.log(`[DataIntegrityManager] 自动备份已启动 (${this.backupInterval / 1000}秒间隔)`); - } - - stopAutoBackup() { - if (this.backupTimer) { - clearInterval(this.backupTimer); - this.backupTimer = null; - console.log('[DataIntegrityManager] 自动备份已停止'); - } - } - - async performAutoBackup() { - try { - const criticalData = await this.getCriticalData(); - if (Object.keys(criticalData).length > 0) { - await this.createBackup(criticalData, 'auto'); - console.log('[DataIntegrityManager] 自动备份完成'); - } else { - console.log('[DataIntegrityManager] 无关键数据需要备份'); - } - } catch (error) { - console.error('[DataIntegrityManager] 自动备份失败:', error); - } - } - - async getBackupList() { - try { - if (!this.repositories) return []; - const backups = await this.repositories.backups.list(); - return backups.map(b => ({ - id: b.id, - timestamp: b.timestamp, - type: b.type, - version: b.version, - size: b.size - })); - } catch (error) { - console.error('[DataIntegrityManager] 获取备份列表失败:', error); - return []; - } - } - - async restoreBackup(backupId) { - let currentSnapshot = null; - try { - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - - // 优先走 BackupAPI:统一还原 records/stats/exam_index/settings - if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') { - try { - currentSnapshot = await this.getCriticalData(); - } catch (snapshotError) { - console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError); - } - await window.BackupAPI.restore(backupId); - console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功 (BackupAPI)`); - return; - } - - const backup = await this.repositories.backups.getById(backupId); - if (!backup) { - throw new Error('备份不存在'); - } - try { - currentSnapshot = await this.getCriticalData(); - } catch (snapshotError) { - console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError); - } - const data = backup.data || {}; - // 缺失 practice_records 时不清空现有记录(settings-only 备份恢复不应删练习数据)。 - // 仅当备份显式包含 practice_records 数组时才恢复。 - const records = Array.isArray(data.practice_records) - ? data.practice_records - : (Array.isArray(data.practiceRecords) ? data.practiceRecords : null); - const stats = data.user_stats || data.userStats || null; - if (records != null) { - await this._restorePracticeRecords(records, stats); - } else if (stats) { - await this._writeUserStats(stats); - } - - if (data.system_settings && typeof data.system_settings === 'object') { - const currentSettings = await this.repositories.settings.getAll(); - const restoredSettings = { ...currentSettings, ...data.system_settings }; - await this.repositories.settings.saveAll(restoredSettings); - } - console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功`); - } catch (error) { - console.error('[DataIntegrityManager] 恢复备份失败:', error); - if (currentSnapshot) { - try { - await this._restoreFromBackup({ data: currentSnapshot }); - console.warn('[DataIntegrityManager] 恢复失败后已回滚到恢复前快照'); - } catch (restoreError) { - console.error('[DataIntegrityManager] 恢复失败后的回滚也失败:', restoreError); - } - } - throw error; - } - } - - async exportData() { - try { - const data = await this.getCriticalData(); - const exportObj = { - exportDate: new Date().toISOString(), - version: this.dataVersion, - data - }; - const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `ielts-data-backup-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - console.log('[DataIntegrityManager] 数据导出成功'); - } catch (error) { - console.error('[DataIntegrityManager] 导出数据失败:', error); - throw error; - } - } - - async importData(source, options = {}) { - this._ensureInitialized(); - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - - let payload; - let backup = null; - try { - payload = await this._normalizeImportPayload(source); - } catch (error) { - console.error('[DataIntegrityManager] 解析导入源失败:', error); - throw new Error(error?.message || '导入文件格式无效'); - } - - const hasPracticeSection = Array.isArray(payload.practice_records); - const hasSettingsSection = payload.system_settings && typeof payload.system_settings === 'object'; - const hasUserStatsSection = payload.user_stats && typeof payload.user_stats === 'object'; - const practiceRecords = hasPracticeSection ? this._preparePracticeRecords(payload.practice_records) : null; - const systemSettings = hasSettingsSection ? this._prepareSystemSettings(payload.system_settings) : {}; - const userStats = hasUserStatsSection ? payload.user_stats : null; - - if (!hasPracticeSection && !hasSettingsSection && !hasUserStatsSection) { - throw new Error('导入文件缺少可用的数据'); - } - - try { - backup = await this.createBackup(null, 'pre_import'); - } catch (error) { - console.warn('[DataIntegrityManager] 导入前创建备份失败:', error); - } - - try { - if (hasPracticeSection) { - await this._restorePracticeRecords(practiceRecords || [], userStats); - } else if (userStats) { - await this._writeUserStats(userStats); - } - - if (hasSettingsSection && Object.keys(systemSettings).length > 0) { - const current = await this.repositories.settings.getAll(); - const next = { ...current, ...systemSettings }; - await this.repositories.settings.saveAll(next); - } - } catch (error) { - console.error('[DataIntegrityManager] 导入数据失败:', error); - // 导入已部分写入:尝试从 pre_import 备份恢复,避免半导入状态损坏数据。 - if (backup && backup.id) { - try { - await this._restoreFromBackup(backup); - console.warn('[DataIntegrityManager] 导入失败后已从备份恢复:', backup.id); - } catch (restoreError) { - console.error('[DataIntegrityManager] 导入失败后恢复备份也失败:', restoreError); - } - } - throw new Error(error?.message || '导入数据失败'); - } - - return { - importedCount: practiceRecords ? practiceRecords.length : 0, - backupId: backup?.id || null, - version: payload.version || this.dataVersion - }; - } - - async getCriticalData() { - this._ensureInitialized(); - try { - if (!this.repositories) { - return {}; - } - const data = {}; - try { - const practiceRecords = await this._listPracticeRecords(); - // 读取失败时用 null 而非 [],区分"读取失败"与"确实无记录"。 - // rollback 时 null 表示不恢复 records,避免用空备份清空好数据。 - data.practice_records = practiceRecords != null ? practiceRecords : null; - } catch (recordsError) { - console.warn('[DataIntegrityManager] 获取练习记录失败:', recordsError); - data.practice_records = null; - } - - try { - const allSettings = await this.repositories.settings.getAll(); - const systemSettings = { - theme: allSettings.theme, - language: allSettings.language, - autoSave: allSettings.autoSave, - notifications: allSettings.notifications - }; - data.system_settings = systemSettings; - } catch (settingsError) { - console.warn('[DataIntegrityManager] 获取系统设置失败:', settingsError); - data.system_settings = {}; - } - - try { - const metaRepo = this.repositories.meta; - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - data.user_stats = await window.PracticeRecordAPI.readStats(); - } - if (metaRepo && typeof metaRepo.get === 'function') { - data.vocab_words = await metaRepo.get('vocab_words', []); - data.vocab_user_config = await metaRepo.get('vocab_user_config', null); - data.vocab_review_queue = await metaRepo.get('vocab_review_queue', []); - data.vocab_list_reading_highlights = await metaRepo.get('vocab_list_reading_highlights', []); - } - } catch (vocabError) { - console.warn('[DataIntegrityManager] 获取词汇数据失败:', vocabError); - } - - return data; - } catch (error) { - console.error('[DataIntegrityManager] 获取关键数据失败:', error); - return {}; - } - } - - async _listPracticeRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } - if (this.repositories && this.repositories.practice && typeof this.repositories.practice.list === 'function') { - const records = await this.repositories.practice.list(); - return Array.isArray(records) ? records : []; - } - - return []; - } - - async _restorePracticeRecords(records, userStats = null) { - const finalRecords = Array.isArray(records) ? records : []; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') { - await window.PracticeRecordAPI.restoreRecords(finalRecords, { - stats: userStats && typeof userStats === 'object' ? userStats : null, - updateStats: true - }); - return true; - } - if (this.repositories && this.repositories.practice && typeof this.repositories.practice.overwrite === 'function') { - await this.repositories.practice.overwrite(finalRecords); - return true; - } - - throw new Error('统一练习记录恢复 API 未就绪'); - } - - async _writeUserStats(stats) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.writeStats === 'function') { - await window.PracticeRecordAPI.writeStats(stats); - return true; - } - if (this.repositories && this.repositories.meta && typeof this.repositories.meta.set === 'function') { - await this.repositories.meta.set('user_stats', stats); - return true; - } - - throw new Error('统一练习统计 API 未就绪'); - } - - // 导入失败时从 pre_import 备份恢复,避免数据停留在半导入状态。 - // 恢复失败仅记录,不掩盖原始导入错误。 - // 注意:practice_records 为 null/undefined 时不恢复 records(读取失败时的占位), - // 只有非 null 的数组才视为有效备份进行恢复;空数组也需恢复(表示备份时确实无记录)。 - async _restoreFromBackup(backup) { - if (!backup || !backup.data) { - return false; - } - const snapshot = backup.data; - const hasRecordsBackup = snapshot.practice_records != null; - const restoredRecords = hasRecordsBackup && Array.isArray(snapshot.practice_records) - ? this._preparePracticeRecords(snapshot.practice_records) - : null; - const restoredStats = snapshot.user_stats && typeof snapshot.user_stats === 'object' - ? snapshot.user_stats - : null; - if (restoredRecords) { - await this._restorePracticeRecords(restoredRecords, restoredStats); - } else if (restoredStats) { - await this._writeUserStats(restoredStats); - } - if (snapshot.system_settings && typeof snapshot.system_settings === 'object' - && Object.keys(snapshot.system_settings).length > 0) { - const current = await this.repositories.settings.getAll(); - const next = { ...current, ...snapshot.system_settings }; - await this.repositories.settings.saveAll(next); - } - return true; - } - - async _normalizeImportPayload(source) { - const raw = await this._resolveImportSource(source); - const container = this._unwrapDataSection(raw); - return { - practice_records: this._extractField(container, ['practice_records', 'practiceRecords', 'practice']), - system_settings: this._extractField(container, ['system_settings', 'systemSettings', 'settings']), - user_stats: this._extractField(container, ['user_stats', 'userStats']), - version: typeof raw?.version === 'string' ? raw.version : null - }; - } - - async _resolveImportSource(source) { - if (!source) { - throw new Error('未提供导入数据源'); - } - if (typeof source === 'string') { - return JSON.parse(source); - } - if (typeof Blob !== 'undefined' && source instanceof Blob && typeof source.text === 'function') { - const text = await source.text(); - return JSON.parse(text); - } - if (typeof File !== 'undefined' && source instanceof File) { - const text = await source.text(); - return JSON.parse(text); - } - if (source instanceof ArrayBuffer) { - const text = new TextDecoder('utf-8').decode(source); - return JSON.parse(text); - } - if (typeof source === 'object') { - return source; - } - throw new Error('不支持的导入数据类型'); - } - - _unwrapDataSection(raw) { - if (!raw || typeof raw !== 'object') { - throw new Error('导入文件格式无效'); - } - if (raw.data && typeof raw.data === 'object') { - return raw.data; - } - return raw; - } - - _extractField(container, variants) { - if (!container || typeof container !== 'object') { - return undefined; - } - const lookup = this._buildKeyLookup(container); - for (const variant of variants) { - if (lookup.has(variant.toLowerCase())) { - return lookup.get(variant.toLowerCase()); - } - } - return undefined; - } - - _buildKeyLookup(container) { - const map = new Map(); - Object.keys(container).forEach((key) => { - map.set(key.toLowerCase(), container[key]); - }); - return map; - } - - _preparePracticeRecords(list) { - if (!Array.isArray(list)) { - return []; - } - return list.filter(entry => entry && typeof entry === 'object'); - } - - _prepareSystemSettings(settings) { - if (!settings || typeof settings !== 'object') { - return {}; - } - const allowed = ['theme', 'language', 'autoSave', 'notifications']; - const prepared = {}; - for (const key of allowed) { - if (settings[key] !== undefined) { - prepared[key] = settings[key]; - } - } - return prepared; - } - -} - -let dataIntegrityManagerInstance = null; - -function getDataIntegrityManager() { - if (!dataIntegrityManagerInstance) { - dataIntegrityManagerInstance = new DataIntegrityManager(); - } - return dataIntegrityManagerInstance; -} - -if (typeof module !== 'undefined' && module.exports) { - module.exports = { DataIntegrityManager, getDataIntegrityManager }; -} else { - window.DataIntegrityManager = DataIntegrityManager; - window.getDataIntegrityManager = getDataIntegrityManager; -} diff --git a/js/components/dataManagementPanel.js b/js/components/dataManagementPanel.js deleted file mode 100644 index 0d598ca6..00000000 --- a/js/components/dataManagementPanel.js +++ /dev/null @@ -1,1106 +0,0 @@ -/** - * 数据管理面板组件 - * 提供数据导入导出、备份恢复的用户界面 - */ -function createElement(tagName, options = {}) { - const el = document.createElement(tagName); - if (options.className) { - el.className = options.className; - } - if (typeof options.text === 'string') { - el.textContent = options.text; - } - if (options.attrs) { - Object.keys(options.attrs).forEach((key) => { - el.setAttribute(key, options.attrs[key]); - }); - } - if (options.dataset) { - Object.keys(options.dataset).forEach((key) => { - el.dataset[key] = options.dataset[key]; - }); - } - return el; -} - -class DataManagementPanel { - constructor(container) { - this.container = container; - this.backupManager = new DataBackupManager(); - this.isVisible = false; - this.selectedFileContent = null; - this.pendingImportMode = null; - - this.initialize(); - } - - /** - * 初始化组件 - */ - async initialize() { - this.createPanelStructure(); - this.bindEvents(); - this.loadDataStats(); - await this.loadHistory(); - - console.log('DataManagementPanel initialized'); - } - - /** - * 创建面板结构 - */ - createPanelStructure() { - const panel = createElement('div', { className: 'data-management-panel' }); - panel.appendChild(this.createHeader()); - panel.appendChild(this.createContent()); - panel.appendChild(this.createProgressOverlay()); - this.container.replaceChildren(panel); - } - - createHeader() { - const header = createElement('div', { className: 'panel-header' }); - const title = createElement('h3'); - const icon = createElement('i', { className: 'fas fa-database' }); - title.appendChild(icon); - title.appendChild(document.createTextNode(' 数据管理')); - - const closeBtn = createElement('button', { - className: 'close-btn', - attrs: { 'data-action': 'close', type: 'button' } - }); - closeBtn.appendChild(createElement('i', { className: 'fas fa-times' })); - - header.appendChild(title); - header.appendChild(closeBtn); - return header; - } - - createContent() { - const content = createElement('div', { className: 'panel-content' }); - content.append( - this.createStatsSection(), - this.createExportSection(), - this.createImportSection(), - this.createCleanupSection(), - this.createHistorySection() - ); - return content; - } - - createStatsSection() { - const section = createElement('div', { className: 'stats-section' }); - section.appendChild(createElement('h4', { text: '数据统计' })); - - const grid = createElement('div', { className: 'stats-grid' }); - const stats = [ - { label: '练习记录', id: 'recordCount' }, - { label: '总练习时间', id: 'totalTime' }, - { label: '平均分数', id: 'avgScore' }, - { label: '存储使用', id: 'storageUsage' } - ]; - - stats.forEach(({ label, id }) => { - const item = createElement('div', { className: 'stat-item' }); - item.appendChild(createElement('span', { className: 'stat-label', text: label })); - const value = createElement('span', { className: 'stat-value', text: '-' }); - value.id = id; - item.appendChild(value); - grid.appendChild(item); - }); - - section.appendChild(grid); - return section; - } - - createExportSection() { - const section = createElement('div', { className: 'export-section' }); - section.appendChild(createElement('h4', { text: '数据导出' })); - - const options = createElement('div', { className: 'export-options' }); - - options.appendChild(this.createSelectGroup('导出格式:', 'exportFormat', [ - { value: 'json', text: 'JSON格式' }, - { value: 'csv', text: 'CSV格式' } - ])); - - options.appendChild(this.createCheckboxGroup({ - id: 'includeStats', - label: '包含用户统计', - checked: true - })); - - options.appendChild(this.createCheckboxGroup({ - id: 'includeBackups', - label: '包含备份数据' - })); - - const dateRange = createElement('div', { className: 'date-range-group' }); - dateRange.appendChild(createElement('label', { text: '时间范围 (可选):' })); - const dateInputs = createElement('div', { className: 'date-inputs' }); - dateInputs.appendChild(createElement('input', { - attrs: { type: 'date', id: 'exportStartDate', placeholder: '开始日期' } - })); - dateInputs.appendChild(createElement('input', { - attrs: { type: 'date', id: 'exportEndDate', placeholder: '结束日期' } - })); - dateRange.appendChild(dateInputs); - options.appendChild(dateRange); - - const exportButton = createElement('button', { - className: 'export-btn', - attrs: { 'data-action': 'export', type: 'button' } - }); - exportButton.appendChild(createElement('i', { className: 'fas fa-download' })); - exportButton.appendChild(document.createTextNode(' 导出数据')); - options.appendChild(exportButton); - - section.appendChild(options); - return section; - } - - createImportSection() { - const section = createElement('div', { className: 'import-section' }); - section.appendChild(createElement('h4', { text: '数据导入' })); - - const options = createElement('div', { className: 'import-options' }); - const fileGroup = createElement('div', { className: 'file-input-group' }); - - const fileInput = createElement('input', { - attrs: { - type: 'file', - id: 'importFile', - accept: '.json,.csv' - } - }); - fileInput.style.display = 'none'; - - const fileButton = createElement('button', { - className: 'file-select-btn', - attrs: { 'data-action': 'selectFile', type: 'button' } - }); - fileButton.appendChild(createElement('i', { className: 'fas fa-file-upload' })); - fileButton.appendChild(document.createTextNode(' 选择文件')); - - const fileName = createElement('span', { - className: 'file-name', - text: '未选择文件' - }); - fileName.id = 'selectedFileName'; - - fileGroup.append(fileInput, fileButton, fileName); - options.appendChild(fileGroup); - - options.appendChild(this.createSelectGroup('导入模式:', 'importMode', [ - { value: 'merge', text: '合并 (保留现有数据)' }, - { value: 'replace', text: '替换 (清空现有数据)' }, - { value: 'skip', text: '跳过 (仅导入新数据)' } - ])); - - options.appendChild(this.createCheckboxGroup({ - id: 'createBackupBeforeImport', - label: '导入前创建备份', - checked: true - })); - - const importButton = createElement('button', { - className: 'import-btn', - attrs: { 'data-action': 'import', type: 'button', disabled: 'disabled' } - }); - importButton.appendChild(createElement('i', { className: 'fas fa-upload' })); - importButton.appendChild(document.createTextNode(' 导入数据')); - options.appendChild(importButton); - - section.appendChild(options); - return section; - } - - createCleanupSection() { - const section = createElement('div', { className: 'cleanup-section' }); - section.appendChild(createElement('h4', { text: '数据清理' })); - - const options = createElement('div', { className: 'cleanup-options' }); - const warning = createElement('div', { className: 'warning-box' }); - warning.appendChild(createElement('i', { className: 'fas fa-exclamation-triangle' })); - warning.appendChild(document.createTextNode(' 数据清理操作不可逆,请谨慎操作!')); - options.appendChild(warning); - - const checkboxContainer = createElement('div', { className: 'cleanup-checkboxes' }); - [ - { id: 'clearRecords', label: '清理练习记录' }, - { id: 'clearStats', label: '清理用户统计' }, - { id: 'clearBackups', label: '清理备份数据' }, - { id: 'clearSettings', label: '清理系统设置' } - ].forEach(({ id, label }) => { - const wrapper = createElement('label'); - const checkbox = createElement('input', { - attrs: { type: 'checkbox', id }, - className: 'cleanup-checkbox' - }); - wrapper.appendChild(checkbox); - wrapper.appendChild(document.createTextNode(` ${label}`)); - checkboxContainer.appendChild(wrapper); - }); - options.appendChild(checkboxContainer); - - options.appendChild(this.createCheckboxGroup({ - id: 'createBackupBeforeClean', - label: '清理前创建备份', - checked: true - })); - - const cleanupButton = createElement('button', { - className: 'cleanup-btn danger', - attrs: { 'data-action': 'cleanup', type: 'button' } - }); - cleanupButton.appendChild(createElement('i', { className: 'fas fa-trash-alt' })); - cleanupButton.appendChild(document.createTextNode(' 执行清理')); - options.appendChild(cleanupButton); - - section.appendChild(options); - return section; - } - - createHistorySection() { - const section = createElement('div', { className: 'history-section' }); - section.appendChild(createElement('h4', { text: '操作历史' })); - - const tabs = createElement('div', { className: 'history-tabs' }); - const exportTab = createElement('button', { - className: 'tab-btn active', - dataset: { tab: 'export' }, - attrs: { type: 'button' } - }); - exportTab.textContent = '导出历史'; - const importTab = createElement('button', { - className: 'tab-btn', - dataset: { tab: 'import' }, - attrs: { type: 'button' } - }); - importTab.textContent = '导入历史'; - tabs.append(exportTab, importTab); - - const content = createElement('div', { className: 'history-content' }); - const exportList = createElement('div', { className: 'history-list' }); - exportList.id = 'exportHistory'; - const importList = createElement('div', { className: 'history-list' }); - importList.id = 'importHistory'; - importList.style.display = 'none'; - content.append(exportList, importList); - - section.append(tabs, content); - return section; - } - - createProgressOverlay() { - const overlay = createElement('div', { - className: 'progress-overlay', - attrs: { id: 'progressOverlay' } - }); - overlay.style.display = 'none'; - - const wrapper = createElement('div', { className: 'progress-content' }); - wrapper.appendChild(createElement('div', { className: 'spinner' })); - const text = createElement('div', { - className: 'progress-text', - text: '处理中...' - }); - text.id = 'progressText'; - wrapper.appendChild(text); - overlay.appendChild(wrapper); - return overlay; - } - - createSelectGroup(labelText, selectId, options) { - const group = createElement('div', { className: 'option-group' }); - const label = createElement('label', { text: labelText }); - const select = createElement('select', { attrs: { id: selectId } }); - options.forEach(({ value, text }) => { - const option = createElement('option', { text }); - option.value = value; - select.appendChild(option); - }); - group.append(label, select); - return group; - } - - createCheckboxGroup({ id, label, checked }) { - const group = createElement('div', { className: 'option-group' }); - const wrapper = createElement('label'); - const input = createElement('input', { - attrs: { type: 'checkbox', id } - }); - if (checked) { - input.checked = true; - } - wrapper.appendChild(input); - wrapper.appendChild(document.createTextNode(` ${label}`)); - group.appendChild(wrapper); - return group; - } - - /** - * 绑定事件 - */ - bindEvents() { - const panel = this.container.querySelector('.data-management-panel'); - - // 使用统一的事件委托处理所有按钮 - panel.addEventListener('click', (e) => { - const button = e.target.closest('[data-action]'); - if (!button) return; - - const action = button.dataset.action; - - switch (action) { - case 'close': - this.hide(); - break; - case 'export': - this.handleExport(); - break; - case 'selectFile': - panel.querySelector('#importFile').click(); - break; - case 'import': - this.beginImportFlow(); - break; - case 'cleanup': - this.handleCleanup(); - break; - } - }); - - // 文件选择change事件仍然需要单独绑定 - panel.querySelector('#importFile').addEventListener('change', (e) => { - this.handleFileSelect(e); - }); - console.log('[DataManagementPanel] 使用统一事件委托处理按钮'); - - // 历史标签切换 - 使用事件委托 - panel.addEventListener('click', (e) => { - const tabBtn = e.target.closest('.tab-btn'); - if (tabBtn) { - this.switchHistoryTab(tabBtn.dataset.tab); - } - }); - - // 清理选项变化监听 - panel.addEventListener('change', (e) => { - if (e.target.classList && e.target.classList.contains('cleanup-checkbox') && e.target.type === 'checkbox') { - this.updateCleanupButton(); - } - }); - - this.updateCleanupButton(); - - // 直接把设置页的“导入数据”按钮也绑到本面板,绕过全局 importData 覆盖混乱 - const settingsImportBtn = document.getElementById('import-data-btn'); - if (settingsImportBtn) { - settingsImportBtn.addEventListener('click', (event) => { - event.preventDefault(); - this.show(); - this.beginImportFlow({ forceModePicker: true }); - }); - } - } - - hasImportSource() { - if (this.selectedFileContent != null) { - return true; - } - const fileInput = document.getElementById('importFile'); - return Boolean(fileInput && fileInput.files && fileInput.files[0]); - } - - resolveImportMode(selectedMode = null) { - const modeSelect = document.getElementById('importMode'); - return selectedMode || this.pendingImportMode || (modeSelect ? modeSelect.value : null) || null; - } - - /** - * 统一导入入口:先确保模式,再在有文件时真正执行导入。 - * 旧实现点击「导入数据」只会打开模式弹窗,永远不调用 handleImport。 - */ - beginImportFlow(options = {}) { - const forceModePicker = options.forceModePicker === true; - const mode = this.resolveImportMode(); - - if (forceModePicker || !mode) { - this.showImportModeModal({ autoImportWhenReady: true }); - return; - } - - if (!this.hasImportSource()) { - this.showMessage('请先选择要导入的文件', 'warning'); - return; - } - - this.handleImport(mode); - } - - showImportModeModal(options = {}) { - const autoImportWhenReady = options.autoImportWhenReady !== false; - this.pendingImportMode = null; - if (!this.importModeModal) { - const overlay = createElement('div', { className: 'import-mode-overlay' }); - Object.assign(overlay.style, { - position: 'fixed', - inset: '0', - background: 'rgba(15,23,42,0.45)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: '16px', - boxSizing: 'border-box', - zIndex: '9999' - }); - - const modal = createElement('div', { className: 'import-mode-modal' }); - Object.assign(modal.style, { - position: 'relative' - }); - - const closeBtn = createElement('button', { className: 'close-btn', text: '×' }); - closeBtn.addEventListener('click', () => this.hideImportModeModal()); - modal.appendChild(closeBtn); - - const title = createElement('h4', { text: '选择导入模式' }); - title.style.marginTop = '0'; - modal.appendChild(title); - - const desc = createElement('p', { text: '选择导入策略。若已选择文件,确认后将立即开始导入。' }); - modal.appendChild(desc); - - const options = createElement('div', { className: 'import-mode-options' }); - const defs = [ - { - mode: 'merge', - icon: '➕', - title: '增量导入', - description: '保留现有记录,仅合并新增或较新记录。' - }, - { - mode: 'replace', - icon: '⚠️', - title: '覆盖导入', - description: '彻底替换现有记录,谨慎操作。' - } - ]; - defs.forEach((def) => { - const card = createElement('div', { className: 'import-mode-option' }); - const icon = createElement('div', { className: 'mode-icon', text: def.icon }); - const titleEl = createElement('h5', { text: def.title }); - const text = createElement('p', { text: def.description }); - const button = createElement('button', { className: 'mode-select-btn', text: '选择' }); - button.addEventListener('click', () => { - this.pendingImportMode = def.mode; - const select = document.getElementById('importMode'); - if (select) { - select.value = def.mode; - } - this.hideImportModeModal(); - - if (autoImportWhenReady && this.hasImportSource()) { - this.handleImport(def.mode); - return; - } - - this.showMessage(`已选择“${def.title}”,请选择文件后再次点击导入。`, 'info'); - }); - card.append(icon, titleEl, text, button); - options.appendChild(card); - }); - modal.appendChild(options); - - const actions = createElement('div', { className: 'import-mode-actions' }); - const cancelBtn = createElement('button', { className: 'btn-cancel', text: '关闭' }); - cancelBtn.addEventListener('click', () => this.hideImportModeModal()); - actions.append(cancelBtn); - modal.appendChild(actions); - - overlay.appendChild(modal); - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - this.hideImportModeModal(); - } - }); - - document.body.appendChild(overlay); - this.importModeModal = overlay; - } - - this.importModeModal.style.display = 'flex'; - } - - hideImportModeModal() { - if (this.importModeModal) { - this.importModeModal.style.display = 'none'; - } - } - - /** - * 显示面板 - */ - async show() { - this.container.style.display = 'block'; - this.isVisible = true; - await this.loadDataStats(); - await this.loadHistory(); - } - - /** - * 隐藏面板 - */ - hide() { - this.container.style.display = 'none'; - this.isVisible = false; - } - - /** - * 加载数据统计 - */ - async loadDataStats() { - try { - const stats = await this.backupManager.getDataStats(); - - if (stats) { - document.getElementById('recordCount').textContent = stats.practiceRecords.count; - document.getElementById('totalTime').textContent = this.formatTime(stats.userStats.totalTimeSpent); - document.getElementById('avgScore').textContent = Math.round(stats.userStats.averageScore * 100) + '%'; - - if (stats.storage) { - const usageKB = Math.round(stats.storage.used / 1024); - document.getElementById('storageUsage').textContent = `${usageKB} KB`; - } - } - } catch (error) { - console.error('Failed to load data stats:', error); - } - } - - /** - * 处理数据导出 - */ - async handleExport() { - try { - this.showProgress('准备导出数据...'); - - const format = document.getElementById('exportFormat').value; - const includeStats = document.getElementById('includeStats').checked; - const includeBackups = document.getElementById('includeBackups').checked; - - const startDate = document.getElementById('exportStartDate').value; - const endDate = document.getElementById('exportEndDate').value; - - const options = { - format, - includeStats, - includeBackups - }; - - if (startDate || endDate) { - options.dateRange = { startDate, endDate }; - } - - const exportResult = await this.backupManager.exportPracticeRecords(options); - - // 下载文件 - this.downloadFile(exportResult.data, exportResult.filename, exportResult.mimeType); - - this.hideProgress(); - this.showMessage('数据导出成功!', 'success'); - this.loadHistory(); - - } catch (error) { - this.hideProgress(); - this.showMessage(`导出失败: ${error.message}`, 'error'); - } - } - - /** - * 处理文件选择 - */ - handleFileSelect(event) { - console.log('[DataManagementPanel] handleFileSelect called'); - const file = event.target.files[0]; - const fileNameSpan = document.getElementById('selectedFileName'); - const importBtn = this.container - ? this.container.querySelector('[data-action="import"]') - : document.querySelector('[data-action="import"]'); - - if (file) { - fileNameSpan.textContent = file.name; - if (importBtn) { - importBtn.disabled = false; - } - - // 异步读取文件内容 - this.readFile(file).then(content => { - try { - this.selectedFileContent = JSON.parse(content); - } catch (_) { - this.selectedFileContent = content; - } - console.log('[DataManagementPanel] File content loaded'); - - // 若模式已通过弹窗选定,选完文件后可直接导入 - if (this.pendingImportMode) { - this.handleImport(this.pendingImportMode); - } - }).catch(error => { - console.error('[DataManagementPanel] Failed to read file:', error); - this.showMessage('文件读取失败', 'error'); - }); - } else { - fileNameSpan.textContent = '未选择文件'; - if (importBtn) { - importBtn.disabled = true; - } - this.selectedFileContent = null; - } - } - - /** - * 处理数据导入 - */ - async handleImport(selectedMode = null) { - console.log('[DataManagementPanel] handleImport called'); - try { - let fileContent; - const fileInput = document.getElementById('importFile'); - const file = fileInput && fileInput.files ? fileInput.files[0] : null; - - if (this.selectedFileContent != null) { - console.log('[DataManagementPanel] using cached file content'); - fileContent = this.selectedFileContent; - } else if (file) { - // 添加文件大小检查 - if (file.size > 5 * 1024 * 1024) { - this.showMessage('文件过大 (>5MB),请分批导入或使用小文件测试。'); - return; - } - this.showProgress('读取文件...'); - // 直接使用FileReader添加详细日志 - fileContent = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = (e) => { - console.error('[DataManagementPanel] File read error:', e); - reject(new Error('文件读取失败')); - }; - reader.onload = (e) => { - console.log('[DataManagementPanel] File loaded, size:', file.size); - try { - const data = JSON.parse(e.target.result); - console.log('[DataManagementPanel] JSON parsed, type:', Array.isArray(data) ? 'array' : typeof data, 'length:', data.length || data.practiceRecords?.length || data.practice_records?.length || data.data?.practice_records?.length); - resolve(data); - } catch (err) { - console.error('[DataManagementPanel] JSON parse error:', err); - reject(err); - } - }; - reader.readAsText(file); - }); - } else { - this.showMessage('请先选择要导入的文件', 'warning'); - return; - } - - this.updateProgress('验证数据格式...'); - - const resolvedMode = this.resolveImportMode(selectedMode); - if (!resolvedMode) { - this.hideProgress(); - this.showImportModeModal({ autoImportWhenReady: true }); - return; - } - - const createBackupEl = document.getElementById('createBackupBeforeImport'); - const createBackup = createBackupEl ? createBackupEl.checked : true; - - const options = { - mergeMode: resolvedMode, - createBackup: createBackup, - validateData: true - }; - - this.updateProgress('导入数据...'); - - const result = await this.backupManager.importPracticeData(fileContent, options); - console.log('[DataManagementPanel] importPracticeData returned:', result); - - this.hideProgress(); - - if (result.success) { - console.log('[DataManagementPanel] Import successful'); - console.log( - 'Import completed: importedCount=', - result.importedCount, - 'total=', - result.recordCount || result.finalCount || result.importedCount || 0 - ); - this.showMessage( - `导入成功!导入 ${result.importedCount || result.recordCount || 0} 条记录,跳过 ${result.skippedCount || 0} 条重复记录。`, - 'success' - ); - this.loadDataStats(); - this.loadHistory(); - - // 清空文件选择 - if (fileInput) { - fileInput.value = ''; - } - const fileNameEl = document.getElementById('selectedFileName'); - if (fileNameEl) { - fileNameEl.textContent = '未选择文件'; - } - const importBtn = this.container - ? this.container.querySelector('[data-action="import"]') - : document.querySelector('[data-action="import"]'); - if (importBtn) { - importBtn.disabled = true; - } - this.selectedFileContent = null; - this.pendingImportMode = null; - } - - } catch (error) { - this.hideProgress(); - console.error('[DataManagementPanel] Import failed:', error); - this.showMessage(`导入失败: ${error.message}`, 'error'); - } - } - - /** - * 处理数据清理 - */ - async handleCleanup() { - const clearRecords = document.getElementById('clearRecords').checked; - const clearStats = document.getElementById('clearStats').checked; - const clearBackups = document.getElementById('clearBackups').checked; - const clearSettings = document.getElementById('clearSettings').checked; - const createBackup = document.getElementById('createBackupBeforeClean').checked; - - if (!clearRecords && !clearStats && !clearBackups && !clearSettings) { - this.showMessage('请选择要清理的数据类型', 'warning'); - return; - } - - // 确认对话框 - const confirmMessage = `确定要清理以下数据吗?\n${ - [ - clearRecords && '• 练习记录', - clearStats && '• 用户统计', - clearBackups && '• 备份数据', - clearSettings && '• 系统设置' - ].filter(Boolean).join('\n') - }\n\n此操作不可撤销!`; - - if (!confirm(confirmMessage)) { - return; - } - - try { - this.showProgress('清理数据...'); - - const options = { - clearPracticeRecords: clearRecords, - clearUserStats: clearStats, - clearBackups: clearBackups, - clearSettings: clearSettings, - createBackup: createBackup - }; - - const result = await this.backupManager.clearData(options); - - this.hideProgress(); - - if (result.success) { - this.showMessage( - `数据清理完成!已清理: ${result.clearedItems.join(', ')}`, - 'success' - ); - this.loadDataStats(); - this.loadHistory(); - - // 重置清理选项 - document.querySelectorAll('.cleanup-checkboxes input[type="checkbox"]').forEach(cb => { - cb.checked = false; - }); - this.updateCleanupButton(); - } - - } catch (error) { - this.hideProgress(); - this.showMessage(`清理失败: ${error.message}`, 'error'); - } - } - - /** - * 切换历史标签 - */ - switchHistoryTab(tab) { - // 更新标签状态 - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.tab === tab); - }); - - // 显示对应内容 - document.getElementById('exportHistory').style.display = tab === 'export' ? 'block' : 'none'; - document.getElementById('importHistory').style.display = tab === 'import' ? 'block' : 'none'; - } - - /** - * 加载操作历史 - */ - async loadHistory() { - await Promise.all([ - this.loadExportHistory(), - this.loadImportHistory() - ]); - } - - /** - * 加载导出历史 - */ - async loadExportHistory() { - const container = document.getElementById('exportHistory'); - if (!container) { - return; - } - - try { - const exportHistory = await this.backupManager.getExportHistory(); - const historyItems = Array.isArray(exportHistory) ? exportHistory : []; - - if (!historyItems.length) { - this.renderNoHistory(container, '暂无导出记录'); - return; - } - - const fragment = document.createDocumentFragment(); - historyItems.forEach((item) => { - fragment.appendChild(this.createHistoryItem({ - icon: 'fas fa-download', - title: `${item.format?.toUpperCase() || 'JSON'} 导出`, - details: [ - `记录数: ${item.recordCount ?? 0}`, - `时间: ${this.formatDateTime(item.timestamp)}` - ] - })); - }); - - container.replaceChildren(fragment); - } catch (error) { - console.error('[DataManagementPanel] Failed to load export history:', error); - this.renderNoHistory(container, '导出历史加载失败'); - } - } - - /** - * 加载导入历史 - */ - async loadImportHistory() { - const container = document.getElementById('importHistory'); - if (!container) { - return; - } - - try { - const importHistory = await this.backupManager.getImportHistory(); - const historyItems = Array.isArray(importHistory) ? importHistory : []; - - if (!historyItems.length) { - this.renderNoHistory(container, '暂无导入记录'); - return; - } - - const fragment = document.createDocumentFragment(); - historyItems.forEach((item) => { - fragment.appendChild(this.createHistoryItem({ - icon: 'fas fa-upload', - title: '导入操作', - details: [ - `新增记录: ${item.recordCount ?? item.importedCount ?? 0}`, - `合并模式: ${item.mergeMode || 'merge'}`, - `时间: ${this.formatDateTime(item.timestamp)}` - ] - })); - }); - - container.replaceChildren(fragment); - } catch (error) { - console.error('[DataManagementPanel] Failed to load import history:', error); - this.renderNoHistory(container, '导入历史加载失败'); - } - } - - renderNoHistory(container, message) { - const empty = createElement('div', { className: 'no-history', text: message }); - container.replaceChildren(empty); - } - - createHistoryItem({ icon, title, details }) { - const item = createElement('div', { className: 'history-item' }); - const info = createElement('div', { className: 'history-info' }); - const titleEl = createElement('div', { className: 'history-title' }); - titleEl.appendChild(createElement('i', { className: icon })); - titleEl.appendChild(document.createTextNode(` ${title}`)); - - const detailsEl = createElement('div', { className: 'history-details' }); - details.forEach((detail) => { - detailsEl.appendChild(createElement('span', { text: detail })); - }); - - info.append(titleEl, detailsEl); - item.appendChild(info); - return item; - } - - /** - * 更新清理按钮状态 - */ - updateCleanupButton() { - const checkboxes = document.querySelectorAll('.cleanup-checkboxes input[type="checkbox"]'); - const cleanupBtn = document.querySelector('[data-action="cleanup"]'); - - const hasSelection = Array.from(checkboxes).some(cb => cb.checked); - cleanupBtn.disabled = !hasSelection; - } - - /** - * 显示进度 - */ - showProgress(text) { - const overlay = document.getElementById('progressOverlay'); - const progressText = document.getElementById('progressText'); - - progressText.textContent = text; - overlay.style.display = 'flex'; - } - - /** - * 更新进度文本 - */ - updateProgress(text) { - const progressText = document.getElementById('progressText'); - progressText.textContent = text; - } - - /** - * 隐藏进度 - */ - hideProgress() { - const overlay = document.getElementById('progressOverlay'); - overlay.style.display = 'none'; - } - - /** - * 显示消息 - */ - showMessage(message, type = 'info') { - // 创建消息元素 - const messageEl = createElement('div', { className: `message-toast ${type}` }); - const icon = createElement('i', { className: `fas fa-${this.getMessageIcon(type)}` }); - const text = createElement('span', { text: message }); - messageEl.append(icon, text); - - // 添加到页面 - document.body.appendChild(messageEl); - - // 自动移除 - setTimeout(() => { - messageEl.remove(); - }, 5000); - } - - /** - * 获取消息图标 - */ - getMessageIcon(type) { - const icons = { - success: 'check-circle', - error: 'exclamation-circle', - warning: 'exclamation-triangle', - info: 'info-circle' - }; - return icons[type] || 'info-circle'; - } - - /** - * 读取文件内容 - */ - readFile(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = (e) => { - resolve(e.target.result); - }; - - reader.onerror = () => { - reject(new Error('文件读取失败')); - }; - - reader.readAsText(file); - }); - } - - /** - * 下载文件 - */ - downloadFile(content, filename, mimeType) { - // 对于文本类型的内容,添加UTF-8编码支持 - const isTextType = mimeType.includes('text/') || - mimeType.includes('application/json') || - mimeType.includes('application/javascript') || - mimeType.includes('application/xml'); - - const blobOptions = isTextType ? { type: mimeType + '; charset=utf-8' } : { type: mimeType }; - const blob = new Blob([content], blobOptions); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.style.display = 'none'; - - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - - URL.revokeObjectURL(url); - } - - /** - * 格式化时间 - */ - formatTime(seconds) { - if (!seconds) return '0分钟'; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - if (hours > 0) { - return `${hours}小时${minutes}分钟`; - } else { - return `${minutes}分钟`; - } - } - - /** - * 格式化日期时间 - */ - formatDateTime(dateString) { - const date = new Date(dateString); - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - } -} - -// 确保全局可用 -window.DataManagementPanel = DataManagementPanel; diff --git a/js/components/goalSettingsPanel.js b/js/components/goalSettingsPanel.js deleted file mode 100644 index bb697ad1..00000000 --- a/js/components/goalSettingsPanel.js +++ /dev/null @@ -1,253 +0,0 @@ -(function (window) { - 'use strict'; - - var TYPE_LABELS = { - practice_count: '练习次数', - study_time: '学习时长 (分钟)', - accuracy: '正确率 (%)' - }; - - var PERIOD_LABELS = { - daily: '每日', - weekly: '每周', - monthly: '每月' - }; - - var TYPE_ICONS = { - practice_count: '📝', - study_time: '⏱️', - accuracy: '🎯' - }; - - function GoalSettingsPanel(options) { - this.container = options.container || null; - this.goalManager = options.goalManager || null; - this.dom = options.domBuilder || (window.DOM && window.DOM.builder); - this.events = options.events || (window.DOM && window.DOM.events); - this._boundRender = this._onGoalUpdate.bind(this); - } - - GoalSettingsPanel.prototype.init = function () { - if (this.goalManager) { - this.goalManager.on('goalUpdated', this._boundRender); - } - }; - - GoalSettingsPanel.prototype.destroy = function () { - if (this.goalManager) { - this.goalManager.off('goalUpdated', this._boundRender); - } - }; - - GoalSettingsPanel.prototype._onGoalUpdate = function () { - this.render(); - }; - - GoalSettingsPanel.prototype.render = function () { - var container = this.container; - if (!container) return; - - if (!this.goalManager || !this.goalManager.ready) { - container.innerHTML = '
加载中...
'; - return; - } - - var goals = this.goalManager.getGoals(); - var allProgress = this.goalManager.getAllProgress(); - var streak = this.goalManager.getStreak(); - - var html = ''; - - // Streak display - html += '
'; - html += '🔥'; - html += '连续学习 ' + streak.current + ''; - if (streak.best > 0) { - html += '最佳 ' + streak.best + ' 天'; - } - html += '
'; - - // Goal list - if (allProgress.length > 0) { - html += '
'; - for (var i = 0; i < allProgress.length; i++) { - html += this._renderGoalCard(allProgress[i]); - } - html += '
'; - } else { - html += '
暂无学习目标,点击下方按钮创建
'; - } - - // Add button - html += '
'; - html += ''; - html += '
'; - - container.innerHTML = html; - this._bindActions(container); - }; - - GoalSettingsPanel.prototype._renderGoalCard = function (progress) { - var goal = progress.goal; - var icon = TYPE_ICONS[goal.type] || '📌'; - var typeLabel = TYPE_LABELS[goal.type] || goal.type; - var periodLabel = PERIOD_LABELS[goal.period] || goal.period; - var percent = progress.percent; - var completed = progress.completed; - var display = goal.type === 'accuracy' - ? Math.round(progress.current * 100) + '%' - : String(progress.current); - - var cls = 'goal-card' + (completed ? ' goal-card-completed' : ''); - var html = '
'; - html += '
'; - html += '' + icon + ''; - html += '' + (goal.title || periodLabel + typeLabel) + ''; - if (completed) { - html += ''; - } - html += ''; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
' + display + ' / ' + goal.target + ' ' + this._unitLabel(goal.type) + '
'; - html += '
'; - html += '
'; - return html; - }; - - GoalSettingsPanel.prototype._unitLabel = function (type) { - if (type === 'practice_count') return '次'; - if (type === 'study_time') return '分钟'; - if (type === 'accuracy') return '%'; - return ''; - }; - - GoalSettingsPanel.prototype._bindActions = function (container) { - var self = this; - var addBtn = container.querySelector('[data-action="add-goal"]'); - if (addBtn) { - addBtn.addEventListener('click', function () { - self._showCreateDialog(); - }); - } - - var deleteBtns = container.querySelectorAll('[data-action="delete-goal"]'); - for (var i = 0; i < deleteBtns.length; i++) { - deleteBtns[i].addEventListener('click', function () { - var gid = this.getAttribute('data-goal-id'); - if (gid && self.goalManager) { - self.goalManager.deleteGoal(gid); - } - }); - } - }; - - GoalSettingsPanel.prototype._showCreateDialog = function () { - var self = this; - var overlay = document.createElement('div'); - overlay.className = 'goal-dialog-overlay'; - - var dialog = document.createElement('div'); - dialog.className = 'goal-dialog'; - dialog.innerHTML = this._renderCreateForm(); - - overlay.appendChild(dialog); - document.body.appendChild(overlay); - - overlay.addEventListener('click', function (e) { - if (e.target === overlay) { - document.body.removeChild(overlay); - } - }); - - var cancelBtn = dialog.querySelector('[data-action="cancel"]'); - if (cancelBtn) { - cancelBtn.addEventListener('click', function () { - document.body.removeChild(overlay); - }); - } - - var saveBtn = dialog.querySelector('[data-action="save"]'); - if (saveBtn) { - saveBtn.addEventListener('click', function () { - var type = dialog.querySelector('#goal-type').value; - var period = dialog.querySelector('#goal-period').value; - var target = Number(dialog.querySelector('#goal-target').value); - var title = dialog.querySelector('#goal-title').value.trim(); - - if (!type || !period || !Number.isFinite(target) || target <= 0) { - if (window.showMessage) { - window.showMessage('请填写完整的目标信息', 'warning'); - } - return; - } - - self.goalManager.createGoal({ - type: type, - period: period, - target: target, - title: title - }); - - document.body.removeChild(overlay); - }); - } - - // Update target placeholder on type change - var typeSelect = dialog.querySelector('#goal-type'); - if (typeSelect) { - typeSelect.addEventListener('change', function () { - var ph = dialog.querySelector('#goal-target'); - if (this.value === 'practice_count') ph.placeholder = '例:3'; - else if (this.value === 'study_time') ph.placeholder = '例:60'; - else if (this.value === 'accuracy') ph.placeholder = '例:80'; - }); - } - }; - - GoalSettingsPanel.prototype._renderCreateForm = function () { - var html = '
'; - html += '

创建学习目标

'; - - html += '
'; - html += ''; - html += ''; - html += '
'; - - html += '
'; - html += ''; - html += ''; - html += '
'; - - html += '
'; - html += ''; - html += ''; - html += '
'; - - html += '
'; - html += ''; - html += ''; - html += '
'; - - html += '
'; - html += ''; - html += ''; - html += '
'; - - html += '
'; - return html; - }; - - window.GoalSettingsPanel = GoalSettingsPanel; -})(typeof window !== 'undefined' ? window : this); diff --git a/js/core/backupAPI.js b/js/core/backupAPI.js deleted file mode 100644 index 300782f4..00000000 --- a/js/core/backupAPI.js +++ /dev/null @@ -1,392 +0,0 @@ -(function initBackupAPI(global) { - 'use strict'; - - if (global.BackupAPI && global.BackupAPI.__stable === true) { - return; - } - - const DEFAULT_VERSION = '0.6.2-form'; - const DEFAULT_MAX_BACKUPS = 20; - - function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); - } - - function cloneJson(value) { - if (value == null) return value; - try { - return JSON.parse(JSON.stringify(value)); - } catch (_) { - return value; - } - } - - function getStorageFacade() { - if (global.storage && typeof global.storage.get === 'function') { - return global.storage; - } - // Some boot paths / VM tests expose bare global storage without attaching to window - try { - if (typeof storage !== 'undefined' && storage && typeof storage.get === 'function') { - return storage; - } - } catch (_) { /* ignore ReferenceError in strict scopes */ } - return null; - } - - function getRepositories() { - if (global.dataRepositories && global.dataRepositories.backups) { - return global.dataRepositories; - } - const registry = global.StorageProviderRegistry; - if (registry && typeof registry.getCurrentProviders === 'function') { - const current = registry.getCurrentProviders(); - if (current && current.repositories && current.repositories.backups) { - return current.repositories; - } - } - if (global.simpleStorageWrapper && global.simpleStorageWrapper.backupRepo) { - return { - backups: global.simpleStorageWrapper.backupRepo, - meta: global.simpleStorageWrapper.metaRepo || null, - settings: global.simpleStorageWrapper.settingsRepo || null - }; - } - return null; - } - - function getBackupRepo() { - const repos = getRepositories(); - return repos && repos.backups ? repos.backups : null; - } - - function getMetaRepo() { - const repos = getRepositories(); - return repos && repos.meta ? repos.meta : null; - } - - async function readMeta(key, fallback = null) { - const meta = getMetaRepo(); - if (meta && typeof meta.get === 'function') { - return await meta.get(key, fallback); - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - return await storageFacade.get(key, fallback); - } - return fallback; - } - - async function writeMeta(key, value) { - const meta = getMetaRepo(); - if (meta && typeof meta.set === 'function') { - await meta.set(key, value); - return true; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set(key, value); - return true; - } - throw new Error('BackupAPI: meta store not ready'); - } - - function resolvePracticeRecords(data) { - if (!data || typeof data !== 'object') return null; - if (Array.isArray(data.practice_records)) return data.practice_records; - if (Array.isArray(data.practiceRecords)) return data.practiceRecords; - return null; - } - - function resolveUserStats(data) { - if (!data || typeof data !== 'object') return null; - if (isPlainObject(data.user_stats)) return data.user_stats; - if (isPlainObject(data.userStats)) return data.userStats; - return null; - } - - function resolveExamIndex(data) { - if (!data || typeof data !== 'object') return null; - if (Array.isArray(data.exam_index)) return data.exam_index; - if (Array.isArray(data.examIndex)) return data.examIndex; - return null; - } - - function resolveStorageVersion(data) { - if (!data || typeof data !== 'object') return null; - if (data.storage_version != null) return data.storage_version; - if (data.storageVersion != null) return data.storageVersion; - return null; - } - - /** - * Canonical dual-schema payload so any legacy restore path can read snake or camel keys. - */ - function normalizePayload(data = {}) { - const source = isPlainObject(data) ? data : {}; - const records = resolvePracticeRecords(source); - const stats = resolveUserStats(source); - const examIndex = resolveExamIndex(source); - const storageVersion = resolveStorageVersion(source); - const payload = { ...source }; - - if (records) { - payload.practice_records = records; - payload.practiceRecords = records; - } - if (stats) { - payload.user_stats = stats; - payload.userStats = stats; - } - if (examIndex) { - payload.exam_index = examIndex; - payload.examIndex = examIndex; - } - if (storageVersion != null) { - payload.storage_version = storageVersion; - payload.storageVersion = storageVersion; - } - return payload; - } - - async function captureSnapshot(extra = {}) { - let practiceRecords = []; - let userStats = null; - - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') { - const listed = await global.PracticeRecordAPI.list(); - practiceRecords = Array.isArray(listed) ? listed : []; - } - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') { - userStats = await global.PracticeRecordAPI.readStats(); - } - - const examIndex = await readMeta('exam_index', []); - const storageVersion = await readMeta('storage_version', null); - - return normalizePayload({ - practice_records: practiceRecords, - user_stats: userStats, - exam_index: Array.isArray(examIndex) ? examIndex : [], - storage_version: storageVersion, - ...(isPlainObject(extra) ? extra : {}) - }); - } - - async function list(options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.list === 'function') { - const backups = await repo.list(options); - return Array.isArray(backups) ? backups : []; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - const backups = await storageFacade.get('manual_backups', []); - return Array.isArray(backups) ? backups : []; - } - throw new Error('BackupAPI.list: backup repository not ready'); - } - - async function getById(id, options = {}) { - if (!id) return null; - const repo = getBackupRepo(); - if (repo && typeof repo.getById === 'function') { - return await repo.getById(id, options); - } - const backups = await list(options); - return backups.find((item) => item && String(item.id) === String(id)) || null; - } - - async function add(backup, options = {}) { - const repo = getBackupRepo(); - const normalizedData = normalizePayload(backup && backup.data ? backup.data : {}); - const entry = { - ...(backup && typeof backup === 'object' ? backup : {}), - id: (backup && backup.id) || `backup_${Date.now()}`, - timestamp: (backup && backup.timestamp) || new Date().toISOString(), - type: (backup && backup.type) || 'manual', - version: (backup && backup.version) || DEFAULT_VERSION, - data: normalizedData - }; - entry.size = entry.size || JSON.stringify(entry.data).length; - - if (repo && typeof repo.add === 'function') { - return await repo.add(entry, options); - } - - // Fallback: raw storage (tests / early boot) - const storageFacade = getStorageFacade(); - if (storageFacade) { - const backups = await storageFacade.get('manual_backups', []); - const list = Array.isArray(backups) ? backups.slice() : []; - list.unshift(entry); - const max = options.maxBackups || DEFAULT_MAX_BACKUPS; - while (list.length > max) { - list.pop(); - } - await storageFacade.set('manual_backups', list); - return entry; - } - - throw new Error('BackupAPI.add: backup repository not ready'); - } - - async function create(options = {}) { - const { - id = null, - type = 'manual', - data = null, - extra = null, - version = DEFAULT_VERSION - } = options; - - const snapshot = data != null - ? normalizePayload(data) - : await captureSnapshot(extra || {}); - - const backupId = id || `backup_${Date.now()}`; - const entry = await add({ - id: backupId, - timestamp: new Date().toISOString(), - type, - version, - data: snapshot - }); - - return entry && entry.id ? entry.id : backupId; - } - - async function restorePayload(data, options = {}) { - const payload = normalizePayload(data || {}); - const records = resolvePracticeRecords(payload); - const stats = resolveUserStats(payload); - const examIndex = resolveExamIndex(payload); - const storageVersion = resolveStorageVersion(payload); - const restoreRecords = options.restoreRecords !== false; - const restoreExamIndex = options.restoreExamIndex !== false; - const restoreStorageVersion = options.restoreStorageVersion !== false; - - if (restoreRecords && records != null) { - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.restoreRecords === 'function') { - await global.PracticeRecordAPI.restoreRecords(records, { - stats: isPlainObject(stats) ? stats : null, - updateStats: true - }); - } else { - throw new Error('BackupAPI.restore: PracticeRecordAPI.restoreRecords not ready'); - } - } else if (isPlainObject(stats) && global.PracticeRecordAPI && typeof global.PracticeRecordAPI.resetStats === 'function') { - await global.PracticeRecordAPI.resetStats(stats); - } - - if (restoreExamIndex && examIndex) { - await writeMeta('exam_index', examIndex); - } - - if (restoreStorageVersion && storageVersion != null) { - await writeMeta('storage_version', storageVersion); - } - - // Optional system settings (DataIntegrityManager snapshots) - if (isPlainObject(payload.system_settings)) { - const repos = getRepositories(); - if (repos && repos.settings && typeof repos.settings.getAll === 'function') { - const current = await repos.settings.getAll(); - await repos.settings.saveAll({ ...current, ...payload.system_settings }); - } - } - - return { - restoredRecords: records != null, - restoredStats: isPlainObject(stats), - restoredExamIndex: Boolean(restoreExamIndex && examIndex), - restoredStorageVersion: Boolean(restoreStorageVersion && storageVersion != null) - }; - } - - async function restore(backupId, options = {}) { - if (!backupId) { - throw new Error('BackupAPI.restore: invalid backup id'); - } - const backup = await getById(backupId); - if (!backup) { - throw new Error(`BackupAPI.restore: backup ${backupId} not found`); - } - const result = await restorePayload(backup.data || {}, options); - return { backup, ...result }; - } - - async function clear(options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.clear === 'function') { - await repo.clear(options); - return true; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set('manual_backups', []); - return true; - } - throw new Error('BackupAPI.clear: backup repository not ready'); - } - - async function remove(id, options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.delete === 'function') { - return await repo.delete(id, options); - } - const backups = await list(); - const next = backups.filter((item) => item && String(item.id) !== String(id)); - if (next.length === backups.length) return false; - if (repo && typeof repo.saveAll === 'function') { - await repo.saveAll(next, options); - return true; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set('manual_backups', next); - return true; - } - return false; - } - - async function prune(limit, options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.prune === 'function') { - return await repo.prune(limit, options); - } - const max = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_MAX_BACKUPS; - const backups = await list(); - if (backups.length <= max) return backups.length; - const next = backups.slice(0, max); - if (repo && typeof repo.saveAll === 'function') { - await repo.saveAll(next, options); - } else { - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set('manual_backups', next); - } - } - return next.length; - } - - global.BackupAPI = { - __stable: true, - version: DEFAULT_VERSION, - list, - getById, - add, - create, - captureSnapshot, - normalizePayload, - restore, - restorePayload, - clear, - remove, - prune, - resolvePracticeRecords, - resolveUserStats, - resolveExamIndex, - resolveStorageVersion - }; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/goalManager.js b/js/core/goalManager.js deleted file mode 100644 index f6857a6c..00000000 --- a/js/core/goalManager.js +++ /dev/null @@ -1,363 +0,0 @@ -(function (window) { - 'use strict'; - - var STORAGE_KEY = 'learning_goals'; - var PROGRESS_KEY = 'goal_progress'; - - var GOAL_TYPES = Object.freeze({ - PRACTICE_COUNT: 'practice_count', - STUDY_TIME: 'study_time', - ACCURACY: 'accuracy' - }); - - var GOAL_PERIODS = Object.freeze({ - DAILY: 'daily', - WEEKLY: 'weekly', - MONTHLY: 'monthly' - }); - - var PERIOD_MS = Object.freeze({ - daily: 24 * 60 * 60 * 1000, - weekly: 7 * 24 * 60 * 60 * 1000, - monthly: 30 * 24 * 60 * 60 * 1000 - }); - - function getNow() { - return new Date().toISOString(); - } - - function todayKey() { - return new Date().toISOString().slice(0, 10); - } - - function weekKey() { - var d = new Date(); - var jan1 = new Date(d.getFullYear(), 0, 1); - var week = Math.ceil(((d - jan1) / 86400000 + jan1.getDay() + 1) / 7); - return d.getFullYear() + '-W' + String(week).padStart(2, '0'); - } - - function monthKey() { - return new Date().toISOString().slice(0, 7); - } - - function periodKey(period) { - if (period === GOAL_PERIODS.DAILY) return todayKey(); - if (period === GOAL_PERIODS.WEEKLY) return weekKey(); - if (period === GOAL_PERIODS.MONTHLY) return monthKey(); - return todayKey(); - } - - function generateId() { - return 'goal_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); - } - - function normalizeGoal(input) { - if (!input || typeof input !== 'object') return null; - var type = input.type; - var period = input.period; - if (!GOAL_TYPES[type]) return null; - if (!GOAL_PERIODS[period]) return null; - - var target = Number(input.target); - if (!Number.isFinite(target) || target <= 0) return null; - - return { - id: typeof input.id === 'string' && input.id ? input.id : generateId(), - type: type, - period: period, - target: Math.floor(target), - title: typeof input.title === 'string' ? input.title.trim() : '', - createdAt: input.createdAt || getNow(), - updatedAt: getNow() - }; - } - - function GoalManager() { - this.goals = []; - this.progress = {}; - this.streak = { current: 0, best: 0, lastDate: null }; - this.ready = false; - this._readyPromise = this._init(); - this._listeners = []; - } - - GoalManager.prototype._init = async function () { - try { - if (window.storage) { - await window.storage.waitForInitialization(); - this.goals = await window.storage.get(STORAGE_KEY, []); - var saved = await window.storage.get(PROGRESS_KEY, null); - if (saved && typeof saved === 'object') { - this.progress = saved.progress || {}; - this.streak = saved.streak || { current: 0, best: 0, lastDate: null }; - } - } else { - try { - var raw = localStorage.getItem(STORAGE_KEY); - this.goals = raw ? JSON.parse(raw) : []; - var rawP = localStorage.getItem(PROGRESS_KEY); - if (rawP) { - var parsed = JSON.parse(rawP); - this.progress = parsed.progress || {}; - this.streak = parsed.streak || { current: 0, best: 0, lastDate: null }; - } - } catch (e) { - this.goals = []; - this.progress = {}; - } - } - this.ready = true; - this._bindEvents(); - } catch (e) { - console.error('[GoalManager] Init failed:', e); - this.ready = true; - } - }; - - GoalManager.prototype._bindEvents = function () { - var self = this; - window.addEventListener('practiceSessionCompleted', function (e) { - self._onPracticeCompleted(e.detail); - }); - }; - - GoalManager.prototype._onPracticeCompleted = async function (detail) { - if (!detail) return; - await this._readyPromise; - - var record = detail.record || detail; - var accuracy = this._extractAccuracy(record); - var duration = this._extractDuration(record); - var pk = todayKey(); - - this._incrementProgress(pk, GOAL_TYPES.PRACTICE_COUNT, 1); - this._updateStreak(); - this._incrementProgress(pk, GOAL_TYPES.STUDY_TIME, Math.round(duration / 60)); - - var weekPk = weekKey(); - this._incrementProgress(weekPk, GOAL_TYPES.STUDY_TIME, Math.round(duration / 60)); - - var monthPk = monthKey(); - this._incrementProgress(monthPk, GOAL_TYPES.STUDY_TIME, Math.round(duration / 60)); - - if (accuracy > 0) { - this._updateAccuracyProgress(GOAL_PERIODS.DAILY, accuracy); - this._updateAccuracyProgress(GOAL_PERIODS.WEEKLY, accuracy); - this._updateAccuracyProgress(GOAL_PERIODS.MONTHLY, accuracy); - } - - await this._save(); - this._checkCompletions(); - this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak }); - }; - - GoalManager.prototype._extractAccuracy = function (record) { - if (!record) return 0; - var candidates = [ - record.accuracy, - record.scoreInfo && record.scoreInfo.accuracy, - record.realData && record.realData.accuracy, - record.realData && record.realData.scoreInfo && record.realData.scoreInfo.accuracy - ]; - for (var i = 0; i < candidates.length; i++) { - var v = Number(candidates[i]); - if (Number.isFinite(v) && v >= 0) { - return v > 1 && v <= 100 ? v / 100 : v; - } - } - return 0; - }; - - GoalManager.prototype._extractDuration = function (record) { - if (!record) return 0; - var candidates = [ - record.duration, - record.scoreInfo && record.scoreInfo.duration, - record.scoreInfo && record.scoreInfo.timeSpent, - record.realData && record.realData.duration, - record.realData && record.realData.scoreInfo && record.realData.scoreInfo.duration - ]; - for (var i = 0; i < candidates.length; i++) { - var v = Number(candidates[i]); - if (Number.isFinite(v) && v >= 0) return v; - } - return 0; - }; - - GoalManager.prototype._incrementProgress = function (pk, type, amount) { - if (!this.progress[pk]) this.progress[pk] = {}; - var current = Number(this.progress[pk][type]) || 0; - this.progress[pk][type] = current + (Number(amount) || 0); - }; - - GoalManager.prototype._updateAccuracyProgress = function (period, accuracy) { - var pk = periodKey(period); - if (!this.progress[pk]) this.progress[pk] = {}; - var acc = this.progress[pk]; - if (!acc[GOAL_TYPES.ACCURACY + '_sum']) { - acc[GOAL_TYPES.ACCURACY + '_sum'] = 0; - acc[GOAL_TYPES.ACCURACY + '_count'] = 0; - } - acc[GOAL_TYPES.ACCURACY + '_sum'] += accuracy; - acc[GOAL_TYPES.ACCURACY + '_count'] += 1; - acc[GOAL_TYPES.ACCURACY] = acc[GOAL_TYPES.ACCURACY + '_sum'] / acc[GOAL_TYPES.ACCURACY + '_count']; - }; - - GoalManager.prototype._updateStreak = function () { - var today = todayKey(); - if (this.streak.lastDate === today) return; - - var yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - var yesterdayKey = yesterday.toISOString().slice(0, 10); - - if (this.streak.lastDate === yesterdayKey) { - this.streak.current += 1; - } else if (this.streak.lastDate !== today) { - this.streak.current = 1; - } - this.streak.lastDate = today; - if (this.streak.current > this.streak.best) { - this.streak.best = this.streak.current; - } - }; - - GoalManager.prototype._checkCompletions = function () { - var self = this; - this.goals.forEach(function (goal) { - var pk = periodKey(goal.period); - var current = self._getGoalCurrent(goal, pk); - if (current >= goal.target) { - self._emit('goalCompleted', { goal: goal, current: current }); - } - }); - }; - - GoalManager.prototype._getGoalCurrent = function (goal, pk) { - if (!this.progress[pk]) return 0; - return Number(this.progress[pk][goal.type]) || 0; - }; - - GoalManager.prototype._save = async function () { - try { - if (window.storage) { - await window.storage.set(STORAGE_KEY, this.goals); - await window.storage.set(PROGRESS_KEY, { progress: this.progress, streak: this.streak }); - } else { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.goals)); - localStorage.setItem(PROGRESS_KEY, JSON.stringify({ progress: this.progress, streak: this.streak })); - } - } catch (e) { - console.error('[GoalManager] Save failed:', e); - } - }; - - GoalManager.prototype._emit = function (name, detail) { - window.dispatchEvent(new CustomEvent(name, { detail: detail })); - }; - - // Public API - - GoalManager.prototype.createGoal = async function (input) { - await this._readyPromise; - var goal = normalizeGoal(input); - if (!goal) return null; - this.goals.push(goal); - await this._save(); - this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak }); - return goal; - }; - - GoalManager.prototype.updateGoal = async function (id, updates) { - await this._readyPromise; - var idx = this.goals.findIndex(function (g) { return g.id === id; }); - if (idx < 0) return null; - var existing = this.goals[idx]; - var merged = { - id: existing.id, - type: updates.type || existing.type, - period: updates.period || existing.period, - target: updates.target !== undefined ? Number(updates.target) : existing.target, - title: updates.title !== undefined ? updates.title : existing.title, - createdAt: existing.createdAt, - updatedAt: getNow() - }; - var normalized = normalizeGoal(merged); - if (!normalized) return null; - this.goals[idx] = normalized; - await this._save(); - this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak }); - return normalized; - }; - - GoalManager.prototype.deleteGoal = async function (id) { - await this._readyPromise; - var before = this.goals.length; - this.goals = this.goals.filter(function (g) { return g.id !== id; }); - if (this.goals.length < before) { - await this._save(); - this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak }); - return true; - } - return false; - }; - - GoalManager.prototype.getGoals = function () { - return this.goals.slice(); - }; - - GoalManager.prototype.getGoalProgress = function (goalId) { - var goal = this.goals.find(function (g) { return g.id === goalId; }); - if (!goal) return null; - var pk = periodKey(goal.period); - var current = this._getGoalCurrent(goal, pk); - return { - goal: goal, - current: current, - target: goal.target, - percent: goal.target > 0 ? Math.min(100, Math.round(current / goal.target * 100)) : 0, - completed: current >= goal.target, - periodKey: pk - }; - }; - - GoalManager.prototype.getAllProgress = function () { - var self = this; - return this.goals.map(function (goal) { - return self.getGoalProgress(goal.id); - }).filter(Boolean); - }; - - GoalManager.prototype.getStreak = function () { - return { - current: this.streak.current, - best: this.streak.best, - lastDate: this.streak.lastDate - }; - }; - - GoalManager.prototype.on = function (eventName, callback) { - this._listeners.push({ event: eventName, callback: callback }); - window.addEventListener(eventName, callback); - }; - - GoalManager.prototype.off = function (eventName, callback) { - this._listeners = this._listeners.filter(function (l) { - return !(l.event === eventName && l.callback === callback); - }); - window.removeEventListener(eventName, callback); - }; - - GoalManager.prototype.destroy = function () { - this._listeners.forEach(function (l) { - window.removeEventListener(l.event, l.callback); - }); - this._listeners = []; - }; - - GoalManager.TYPES = GOAL_TYPES; - GoalManager.PERIODS = GOAL_PERIODS; - - window.GoalManager = GoalManager; -})(typeof window !== 'undefined' ? window : this); diff --git a/js/core/practiceRecordAPI.js b/js/core/practiceRecordAPI.js deleted file mode 100644 index 18d91ca9..00000000 --- a/js/core/practiceRecordAPI.js +++ /dev/null @@ -1,883 +0,0 @@ -(function initPracticeRecordAPI(global) { - 'use strict'; - - const DEFAULT_VERSION = '0.6.2-fix'; - const DEFAULT_MAX_RECORDS = 1000; - - if (global.PracticeRecordAPI && global.PracticeRecordAPI.__stable === true) { - return; - } - - let recordStore = null; - - function getPracticeCore() { - return global.PracticeCore || null; - } - - function installRecordStore() { - if (recordStore) { - return recordStore; - } - const core = getPracticeCore(); - if (!core || typeof core.__installRecordAPI !== 'function') { - return null; - } - recordStore = core.__installRecordAPI((store) => store || null); - try { - delete core.__installRecordAPI; - } catch (_) { - core.__installRecordAPI = undefined; - } - return recordStore; - } - - function getRecordStore() { - return recordStore || installRecordStore(); - } - - installRecordStore(); - - function getDefaultSaveOptions(options = {}) { - const source = options && typeof options === 'object' ? options : {}; - const normalized = { - currentVersion: source.currentVersion || DEFAULT_VERSION, - maxRecords: DEFAULT_MAX_RECORDS - }; - const maxRecords = Number(source.maxRecords); - if (Number.isFinite(maxRecords) && maxRecords > 0) { - normalized.maxRecords = maxRecords; - } - Object.keys(source).forEach((key) => { - if (source[key] !== undefined) { - normalized[key] = source[key]; - } - }); - normalized.currentVersion = normalized.currentVersion || DEFAULT_VERSION; - normalized.maxRecords = Number.isFinite(Number(normalized.maxRecords)) && Number(normalized.maxRecords) > 0 - ? Number(normalized.maxRecords) - : DEFAULT_MAX_RECORDS; - return normalized; - } - - function toIdString(value) { - return value == null ? '' : String(value); - } - - function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); - } - - function clonePlainObject(value) { - if (value == null || typeof value !== 'object') { - return value ?? null; - } - if (Array.isArray(value)) { - return value.map((item) => clonePlainObject(item)); - } - const clone = {}; - Object.keys(value).forEach((key) => { - clone[key] = clonePlainObject(value[key]); - }); - return clone; - } - - function getDefaultStats() { - if (global.ExamData && typeof global.ExamData.createDefaultUserStats === 'function') { - return clonePlainObject(global.ExamData.createDefaultUserStats()); - } - const now = new Date().toISOString(); - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [], - createdAt: now, - updatedAt: now - }; - } - - function toCamelCaseKey(key) { - return String(key) - .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase()) - .replace(/^[A-Z]/, match => match.toLowerCase()); - } - - function normalizeStatsAliases(stats) { - if (!isPlainObject(stats)) { - return {}; - } - const normalized = {}; - Object.entries(stats).forEach(([key, value]) => { - normalized[toCamelCaseKey(key)] = value; - }); - return normalized; - } - - function prepareStats(stats) { - const source = normalizeStatsAliases(stats); - const prepared = Object.assign({}, getDefaultStats(), clonePlainObject(source)); - prepared.categoryStats = isPlainObject(source.categoryStats) ? clonePlainObject(source.categoryStats) : {}; - prepared.questionTypeStats = isPlainObject(source.questionTypeStats) ? clonePlainObject(source.questionTypeStats) : {}; - prepared.practiceDays = Array.isArray(source.practiceDays) ? source.practiceDays.slice() : []; - prepared.achievements = Array.isArray(source.achievements) ? source.achievements.slice() : []; - prepared.updatedAt = source.updatedAt || new Date().toISOString(); - return prepared; - } - - function getCoreContracts() { - const core = getPracticeCore(); - return core && core.contracts ? core.contracts : null; - } - - function normalizeRecord(record, options = {}) { - if (!isPlainObject(record)) { - return null; - } - - const contracts = getCoreContracts(); - if (!contracts || typeof contracts.standardizeRecord !== 'function') { - throw new Error('PracticeRecordAPI.normalizeRecord: PracticeCore.contracts.standardizeRecord not ready'); - } - - const preserveIds = options.preserveIds !== false; - const safePrefix = options.fallbackIdPrefix || 'record'; - const sourceId = record.id - ?? record.recordId - ?? record.record_id - ?? record.practiceId - ?? record.practice_id - ?? record.sessionId - ?? record.sessionID - ?? record.timestamp - ?? record.uuid; - const candidate = clonePlainObject(record) || {}; - - let id = preserveIds && sourceId ? String(sourceId).trim() : ''; - if (!id) { - const index = Number.isFinite(Number(options.index)) ? Number(options.index) : 0; - id = `${safePrefix}_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`; - } - candidate.id = id; - - if (record.recordStatus !== undefined && candidate.status === undefined) { - candidate.status = record.recordStatus; - } - - const generateRecordId = typeof options.generateRecordId === 'function' - ? options.generateRecordId - : () => id; - const standardized = contracts.standardizeRecord(candidate, Object.assign({}, options, { - currentVersion: options.currentVersion || DEFAULT_VERSION, - generateRecordId - })); - return standardized && standardized.examId ? standardized : null; - } - - function normalizeDateValue(value) { - if (!value) { - return null; - } - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString(); - } - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (/^\d+$/.test(trimmed)) { - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000; - return new Date(milliseconds).toISOString(); - } - } - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return null; - } - - function getRecordTimestamp(record) { - if (!record || typeof record !== 'object') { - return 0; - } - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.timestamp, - record.date - ]; - for (let index = 0; index < candidates.length; index += 1) { - const iso = normalizeDateValue(candidates[index]); - if (iso) { - const time = new Date(iso).getTime(); - if (Number.isFinite(time)) { - return time; - } - } - } - return 0; - } - - function mergeRecordDetails(existing, incoming, options = {}) { - const merged = Object.assign({}, existing || {}, incoming || {}); - if (isPlainObject(existing && existing.metadata) || isPlainObject(incoming && incoming.metadata)) { - merged.metadata = Object.assign( - {}, - isPlainObject(existing && existing.metadata) ? existing.metadata : {}, - isPlainObject(incoming && incoming.metadata) ? incoming.metadata : {} - ); - } - if (isPlainObject(existing && existing.realData) || isPlainObject(incoming && incoming.realData)) { - merged.realData = Object.assign( - {}, - isPlainObject(existing && existing.realData) ? existing.realData : {}, - isPlainObject(incoming && incoming.realData) ? incoming.realData : {} - ); - } - return normalizeRecord(merged, Object.assign({}, options, { - generateRecordId: () => String(merged.id || (incoming && incoming.id) || (existing && existing.id) || `record_${Date.now()}`) - })); - } - - async function readStats(options = {}) { - const fallback = Object.prototype.hasOwnProperty.call(options, 'fallback') - ? options.fallback - : getDefaultStats(); - - const store = getRecordStore(); - if (!store || typeof store.readMeta !== 'function') { - throw new Error('PracticeRecordAPI.readStats: unified meta store not ready'); - } - - return prepareStats(await store.readMeta('user_stats', fallback)); - } - - async function writeStats(stats) { - const finalStats = prepareStats(stats); - const store = getRecordStore(); - - if (store && typeof store.writeMeta === 'function') { - await store.writeMeta('user_stats', finalStats); - return finalStats; - } - - throw new Error('PracticeRecordAPI.writeStats: unified meta store not ready'); - } - - function normalizeDay(value) { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - return date.toISOString().slice(0, 10); - } - - function calculateStreakDays(days) { - const sorted = Array.isArray(days) ? days.slice().sort() : []; - if (sorted.length === 0) return 0; - let streak = 1; - for (let index = sorted.length - 1; index > 0; index -= 1) { - const current = new Date(sorted[index]); - const previous = new Date(sorted[index - 1]); - const diffDays = Math.round((current - previous) / 86400000); - if (diffDays === 1) { - streak += 1; - continue; - } - if (diffDays > 1) break; - } - return streak; - } - - function normalizeAccuracyForStats(record) { - const values = [ - record && record.accuracy, - record && record.scoreInfo && record.scoreInfo.accuracy, - record && record.realData && record.realData.scoreInfo && record.realData.scoreInfo.accuracy - ]; - for (let index = 0; index < values.length; index += 1) { - const numeric = Number(values[index]); - if (Number.isFinite(numeric)) { - if (numeric > 1 && numeric <= 100) { - return numeric / 100; - } - return Math.max(0, Math.min(1, numeric)); - } - } - const correct = Number(record && (record.correctAnswers ?? record.scoreInfo?.correct ?? record.score)); - const total = Number(record && (record.totalQuestions ?? record.scoreInfo?.total)); - return Number.isFinite(correct) && Number.isFinite(total) && total > 0 - ? Math.max(0, Math.min(1, correct / total)) - : 0; - } - - function applyRecordToStats(stats, record) { - if (!stats || !record || typeof record !== 'object') { - return; - } - - const duration = Math.max(0, Number(record.duration) || 0); - const accuracy = normalizeAccuracyForStats(record); - const category = String((record.metadata && record.metadata.category) || record.category || record.type || '').trim(); - const day = normalizeDay(record.date || record.endTime || record.startTime || record.createdAt); - - stats.totalPractices += 1; - stats.totalTimeSpent += duration; - const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy; - stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0; - - stats.categoryStats = isPlainObject(stats.categoryStats) ? stats.categoryStats : {}; - if (category) { - if (!stats.categoryStats[category]) { - stats.categoryStats[category] = { - practices: 0, - avgScore: 0, - timeSpent: 0, - bestScore: 0, - totalQuestions: 0, - correctAnswers: 0 - }; - } - const categoryStats = stats.categoryStats[category]; - categoryStats.practices += 1; - categoryStats.timeSpent += duration; - categoryStats.bestScore = Math.max(categoryStats.bestScore || 0, accuracy); - categoryStats.totalQuestions += Number(record.totalQuestions) || 0; - categoryStats.correctAnswers += Number(record.correctAnswers) || 0; - categoryStats.avgScore = ((categoryStats.avgScore || 0) * (categoryStats.practices - 1) + accuracy) / categoryStats.practices; - } - - stats.questionTypeStats = isPlainObject(stats.questionTypeStats) ? stats.questionTypeStats : {}; - if (isPlainObject(record.questionTypePerformance)) { - Object.entries(record.questionTypePerformance).forEach(([type, performance]) => { - if (!stats.questionTypeStats[type]) { - stats.questionTypeStats[type] = { - practices: 0, - accuracy: 0, - totalQuestions: 0, - correctAnswers: 0 - }; - } - const typeStats = stats.questionTypeStats[type]; - typeStats.practices += 1; - typeStats.totalQuestions += Number(performance && performance.total) || 0; - typeStats.correctAnswers += Number(performance && performance.correct) || 0; - typeStats.accuracy = typeStats.totalQuestions > 0 - ? typeStats.correctAnswers / typeStats.totalQuestions - : 0; - }); - } - - if (day) { - const days = new Set(Array.isArray(stats.practiceDays) ? stats.practiceDays : []); - days.add(day); - stats.practiceDays = Array.from(days).sort(); - stats.lastPracticeDate = stats.practiceDays[stats.practiceDays.length - 1] || null; - stats.streakDays = calculateStreakDays(stats.practiceDays); - } - stats.updatedAt = new Date().toISOString(); - } - - async function recalculateStats() { - // 使用轻量 listSummary 避免反序列化+克隆完整记录(answers/suiteEntries/realData 等重字段)。 - // summary 已包含 applyRecordToStats 所需的全部字段:duration, accuracy, metadata.category, - // date/endTime/startTime/createdAt, totalQuestions, correctAnswers, questionTypePerformance。 - const records = await listSummary(); - const stats = getDefaultStats(); - (Array.isArray(records) ? records : []).forEach((record) => applyRecordToStats(stats, record)); - return await writeStats(stats); - } - - async function resetStats(stats = null) { - return await writeStats(isPlainObject(stats) ? stats : getDefaultStats()); - } - - async function mergeStats(stats, options = {}) { - if (!isPlainObject(stats)) { - return await readStats(); - } - - const mergeMode = options.mergeMode || options.mode || 'merge'; - if (mergeMode === 'replace') { - return await writeStats(stats); - } - - const existing = await readStats({ fallback: {} }); - const merged = Object.assign({}, existing); - Object.entries(stats).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - const current = existing[key]; - if (typeof value === 'number' && typeof current === 'number') { - merged[key] = Math.max(value, current); - return; - } - if (isPlainObject(value) && isPlainObject(current)) { - merged[key] = Object.assign({}, current, value); - return; - } - merged[key] = clonePlainObject(value); - }); - - return await writeStats(merged); - } - - async function updateStatsForSavedRecord(record, options = {}) { - if (!record || options.updateStats === false) { - return false; - } - - await recalculateStats(); - return true; - } - - async function list() { - const store = getRecordStore(); - if (!store || typeof store.listPracticeRecords !== 'function') { - throw new Error('PracticeRecordAPI.list: unified store not ready'); - } - - const records = await store.listPracticeRecords(); - return Array.isArray(records) ? records : []; - } - - /** - * 轻量投影查询:返回每条记录的元数据摘要,不含 answers/correctAnswerMap/ - * suiteEntries[]/realData 等重字段。底层以 clone:false 读取原始数组后即时映射, - * 避免大数据量下 structuredClone 全部记录导致内存溢出和渲染卡顿。 - * 供练习历史列表签名、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用。 - */ - async function listSummary(options = {}) { - const store = getRecordStore(); - if (!store || typeof store.listPracticeRecordSummaries !== 'function') { - // 回退:store 尚未支持 summary 时从完整记录投影 - const records = await list(); - return records.map(_projectSummary).filter(Boolean); - } - const summaries = await store.listPracticeRecordSummaries(); - return Array.isArray(summaries) ? summaries : []; - } - - /** 返回记录总数,不加载记录数组到内存 */ - async function count(options = {}) { - const store = getRecordStore(); - if (store && typeof store.countPracticeRecords === 'function') { - return await store.countPracticeRecords(); - } - // 回退:store 不支持 count 时从 summary 长度获取 - if (store && typeof store.listPracticeRecordSummaries === 'function') { - const summaries = await store.listPracticeRecordSummaries(); - return Array.isArray(summaries) ? summaries.length : 0; - } - const records = await list(); - return Array.isArray(records) ? records.length : 0; - } - - /** 返回去重后的 examId 列表,供 overview 统计使用 */ - async function distinctExamIds(options = {}) { - const summaries = await listSummary(options); - const seen = new Set(); - const result = []; - for (let i = 0; i < summaries.length; i += 1) { - const examId = summaries[i] && summaries[i].examId; - if (examId && !seen.has(examId)) { - seen.add(examId); - result.push(examId); - } - } - return result; - } - - /** 纯函数投影:从单条完整记录提取轻量 summary */ - function _projectSummary(record) { - if (!record || typeof record !== 'object') { - return null; - } - const scoreInfo = record.scoreInfo || {}; - const metadata = record.metadata || {}; - // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData - const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : []; - const suiteEntries = rawSuiteEntries.map(function (entry) { - if (!entry || typeof entry !== 'object') { return null; } - const entryMeta = entry.metadata || {}; - const entryScore = entry.scoreInfo || {}; - return { - id: entry.id || '', - examId: entry.examId || entryMeta.examId || '', - title: entry.title || entryMeta.examTitle || '', - percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0, - duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0 - }; - }).filter(Boolean); - return { - id: record.id || record.sessionId || '', - sessionId: record.sessionId || null, - examId: record.examId || metadata.examId || null, - title: record.title || metadata.examTitle || '', - type: record.type || metadata.type || 'reading', - practiceType: record.practiceType || metadata.practiceType || metadata.examType || null, - url: record.url || metadata.url || null, - startTime: record.startTime || null, - endTime: record.endTime || null, - date: record.date || null, - duration: Number(record.duration != null ? record.duration : (scoreInfo.duration != null ? scoreInfo.duration : scoreInfo.timeSpent)) || 0, - percentage: Number(record.percentage != null ? record.percentage : scoreInfo.percentage) || 0, - accuracy: Number(record.accuracy != null ? record.accuracy : scoreInfo.accuracy) || 0, - score: Number(record.score != null ? record.score : scoreInfo.score) || 0, - totalQuestions: Number(record.totalQuestions != null ? record.totalQuestions : scoreInfo.total) || 0, - correctAnswers: Number(record.correctAnswers != null ? record.correctAnswers : scoreInfo.correct) || 0, - status: record.status || 'completed', - suiteMode: Boolean(record.suiteMode), - suiteEntryCount: rawSuiteEntries.length, - suiteEntries: suiteEntries, - suiteSessionId: record.suiteSessionId || metadata.suiteSessionId || null, - questionTypePerformance: record.questionTypePerformance || null, - scoreInfo: { - accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null, - duration: scoreInfo.duration != null ? scoreInfo.duration : null, - timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null, - percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null, - score: scoreInfo.score != null ? scoreInfo.score : null, - total: scoreInfo.total != null ? scoreInfo.total : null, - correct: scoreInfo.correct != null ? scoreInfo.correct : null - }, - metadata: { - category: metadata.category || record.category || null, - examTitle: metadata.examTitle || record.title || '', - frequency: metadata.frequency || record.frequency || 'unknown', - type: metadata.type || record.type || null, - examType: metadata.examType || null, - practiceType: metadata.practiceType || null, - examId: metadata.examId || null, - title: metadata.title || null, - url: metadata.url || null - }, - updatedAt: record.updatedAt || null, - createdAt: record.createdAt || null - }; - } - - async function getById(recordId) { - const targetId = toIdString(recordId); - if (!targetId) { - return null; - } - const records = await list(); - return records.find((record) => { - if (!record || typeof record !== 'object') { - return false; - } - return toIdString(record.id) === targetId || toIdString(record.sessionId) === targetId; - }) || null; - } - - async function replace(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('PracticeRecordAPI.replace requires an array of records'); - } - const finalRecords = records; - const saveOptions = getDefaultSaveOptions(options); - const store = getRecordStore(); - if (store && typeof store.replacePracticeRecords === 'function') { - await store.replacePracticeRecords(finalRecords, saveOptions); - if (options.updateStats !== false) { - await recalculateStats(); - } - return finalRecords; - } - - throw new Error('PracticeRecordAPI.replace: unified store not ready'); - } - - async function mergeRecords(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('PracticeRecordAPI.mergeRecords requires an array of records'); - } - - const mergeMode = options.mergeMode || options.mode || 'merge'; - const normalizeOptions = getDefaultSaveOptions(options); - const incomingRecords = records - .map((record, index) => normalizeRecord(record, Object.assign({}, normalizeOptions, { - preserveIds: options.preserveIds !== false, - fallbackIdPrefix: options.fallbackIdPrefix || 'record', - index - }))) - .filter(Boolean); - const existingRecords = await list(); - - if (mergeMode === 'replace') { - await replace(incomingRecords, Object.assign({}, options, { updateStats: options.updateStats !== false })); - return { - importedCount: incomingRecords.length, - updatedCount: existingRecords.length, - skippedCount: 0, - finalCount: incomingRecords.length, - records: incomingRecords - }; - } - - const indexMap = new Map(); - existingRecords.forEach((record, index) => { - if (record && record.id !== undefined && record.id !== null) { - indexMap.set(String(record.id), { record, index }); - } - }); - - const mergedRecords = existingRecords.slice(); - let importedCount = 0; - let updatedCount = 0; - let skippedCount = 0; - - incomingRecords.forEach((record) => { - if (!record || record.id === undefined || record.id === null) { - return; - } - - const key = String(record.id); - const existing = indexMap.get(key); - - if (!existing) { - mergedRecords.push(record); - indexMap.set(key, { record, index: mergedRecords.length - 1 }); - importedCount += 1; - return; - } - - if (mergeMode === 'skip') { - skippedCount += 1; - return; - } - - const existingTimestamp = getRecordTimestamp(existing.record); - const incomingTimestamp = getRecordTimestamp(record); - if (incomingTimestamp >= existingTimestamp) { - const merged = mergeRecordDetails(existing.record, record, normalizeOptions); - mergedRecords[existing.index] = merged; - indexMap.set(key, { record: merged, index: existing.index }); - updatedCount += 1; - return; - } - - skippedCount += 1; - }); - - mergedRecords.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a)); - await replace(mergedRecords, Object.assign({}, options, { updateStats: options.updateStats !== false })); - - return { - importedCount, - updatedCount, - skippedCount, - finalCount: mergedRecords.length, - records: mergedRecords - }; - } - - async function restoreRecords(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('PracticeRecordAPI.restoreRecords requires an array of records'); - } - - await replace(records, Object.assign({}, options, { updateStats: false })); - if (isPlainObject(options.stats)) { - await writeStats(options.stats); - } else if (options.updateStats !== false) { - await recalculateStats(); - } - return { - restoredCount: records.length, - statsRestored: isPlainObject(options.stats) - }; - } - - async function clear(options = {}) { - await replace([], Object.assign({}, options, { updateStats: false })); - if (options.updateStats === true) { - await resetStats(); - } - return true; - } - - async function deleteMany(recordIds, options = {}) { - const ids = Array.isArray(recordIds) ? recordIds.map(toIdString).filter(Boolean) : []; - if (ids.length === 0) { - return { deletedCount: 0, deletedRecords: [], records: await list() }; - } - - const idSet = new Set(ids); - // 默认仅按 record.id 删除,避免共享 sessionId 的不同记录被误删。 - // matchBy: 'sessionId' 时才按 sessionId 匹配(用于 suite 子记录清理等显式场景)。 - const matchBySessionId = options.matchBy === 'sessionId'; - const records = await list(); - const deletedRecords = []; - const remainingRecords = []; - - (Array.isArray(records) ? records : []).forEach((record) => { - const recordId = toIdString(record && record.id); - const sessionId = toIdString(record && record.sessionId); - const idMatch = recordId && idSet.has(recordId); - const sessionMatch = matchBySessionId && sessionId && idSet.has(sessionId); - if (idMatch || sessionMatch) { - deletedRecords.push(record); - return; - } - remainingRecords.push(record); - }); - - if (deletedRecords.length > 0) { - await replace(remainingRecords, options); - } - - return { - deletedCount: deletedRecords.length, - deletedRecords, - records: remainingRecords - }; - } - - async function deleteById(recordId, options = {}) { - const result = await deleteMany([recordId], options); - return { - deleted: result.deletedCount > 0, - record: result.deletedRecords[0] || null, - records: result.records - }; - } - - async function saveRecord(record, options = {}) { - if (!record || typeof record !== 'object') { - throw new Error('PracticeRecordAPI.saveRecord requires a record object'); - } - - const saveOptions = getDefaultSaveOptions(options); - const store = getRecordStore(); - if (!store || typeof store.savePracticeRecord !== 'function') { - throw new Error('PracticeRecordAPI.saveRecord: PracticeCore store not ready'); - } - const normalizedRecord = normalizeRecord(record, saveOptions); - if (!normalizedRecord || !normalizedRecord.examId) { - throw new Error('PracticeRecordAPI.saveRecord requires a canonical examId'); - } - - const savedRecord = await store.savePracticeRecord(normalizedRecord, saveOptions); - - if (options.updateStats !== false) { - await updateStatsForSavedRecord(savedRecord, options); - } - - return savedRecord; - } - - function fromCompletion(payload, context = {}, examEntry = null, options = {}) { - const core = getPracticeCore(); - if (!core || !core.ingestor || typeof core.ingestor.fromCompletion !== 'function') { - return null; - } - return core.ingestor.fromCompletion(payload, context || {}, examEntry || null, getDefaultSaveOptions(options)); - } - - async function saveCompletion(payload, context = {}, examEntry = null, options = {}) { - const record = fromCompletion(payload, context, examEntry, options); - if (!record) { - throw new Error('PracticeRecordAPI.saveCompletion could not build canonical record'); - } - return await saveRecord(record, options); - } - - function normalizeAccuracy(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric < 0) { - return 0; - } - if (numeric > 1 && numeric <= 100) { - return numeric / 100; - } - return Math.min(numeric, 1); - } - - function toSummaryMetrics(record = {}) { - const total = Number(record.totalQuestions ?? record.scoreInfo?.total ?? record.scoreInfo?.totalQuestions ?? record.realData?.scoreInfo?.total ?? record.realData?.totalQuestions); - const correct = Number(record.correctAnswers ?? record.score ?? record.scoreInfo?.correct ?? record.scoreInfo?.score ?? record.realData?.scoreInfo?.correct ?? record.realData?.score); - const safeTotal = Number.isFinite(total) && total >= 0 ? total : 0; - const safeCorrect = Number.isFinite(correct) && correct >= 0 ? correct : 0; - - let accuracy = normalizeAccuracy(record.accuracy ?? record.scoreInfo?.accuracy ?? record.realData?.scoreInfo?.accuracy ?? (safeTotal > 0 ? safeCorrect / safeTotal : 0)); - const percentageCandidate = Number(record.percentage ?? record.scoreInfo?.percentage ?? record.realData?.scoreInfo?.percentage); - const percentage = Number.isFinite(percentageCandidate) && percentageCandidate >= 0 && percentageCandidate <= 100 - ? percentageCandidate - : Math.round(accuracy * 100); - const hasExplicitAccuracy = record.accuracy != null - || record.scoreInfo?.accuracy != null - || record.realData?.scoreInfo?.accuracy != null; - accuracy = percentage > 1 && !hasExplicitAccuracy - ? percentage / 100 - : accuracy; - - return { - totalQuestions: safeTotal, - correctAnswers: safeCorrect, - accuracy, - percentage, - duration: Number(record.duration ?? record.realData?.duration) || 0 - }; - } - - function toReplayEntries(record, projector) { - if (typeof projector === 'function') { - return projector(record); - } - return []; - } - - global.PracticeRecordAPI = { - __stable: true, - version: '0.6.2-fix', - list, - listSummary, - count, - distinctExamIds, - getById, - replace, - mergeRecords, - restoreRecords, - clear, - deleteById, - deleteMany, - saveRecord, - normalizeRecord, - fromCompletion, - saveCompletion, - toSummaryMetrics, - toReplayEntries, - getDefaultStats, - prepareStats, - readStats, - writeStats, - mergeStats, - resetStats, - recalculateStats, - updateStatsForSavedRecord - }; - - if (global.persistentStore && typeof global.persistentStore.migrateLegacyData === 'function') { - Promise.resolve() - .then(() => global.persistentStore.migrateLegacyData({ skipReady: true })) - .catch((error) => { - console.warn('[PracticeRecordAPI] 延后练习记录迁移失败:', error); - }); - } -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/practiceStore.js b/js/core/practiceStore.js deleted file mode 100644 index 2f3ed5fb..00000000 --- a/js/core/practiceStore.js +++ /dev/null @@ -1,53 +0,0 @@ -(function initPracticeStore(global) { - 'use strict'; - - function getPracticeRecordAPI() { - if (!global.PracticeRecordAPI) { - throw new Error('PracticeStore: PracticeRecordAPI not ready'); - } - return global.PracticeRecordAPI; - } - - async function list() { - var api = getPracticeRecordAPI(); - if (typeof api.list !== 'function') { - throw new Error('PracticeStore.list: PracticeRecordAPI.list not ready'); - } - var records = await api.list(); - return Array.isArray(records) ? records : []; - } - - async function replace(records, options) { - var finalRecords = Array.isArray(records) ? records : []; - var api = getPracticeRecordAPI(); - if (typeof api.replace !== 'function') { - throw new Error('PracticeStore.replace: PracticeRecordAPI.replace not ready'); - } - await api.replace(finalRecords, Object.assign({ updateStats: true }, options || {})); - return true; - } - - async function save(record, options) { - var api = getPracticeRecordAPI(); - if (typeof api.saveRecord !== 'function') { - throw new Error('PracticeStore.save: PracticeRecordAPI.saveRecord not ready'); - } - return api.saveRecord(record, Object.assign({ updateStats: true }, options || {})); - } - - async function clear(options) { - var api = getPracticeRecordAPI(); - if (typeof api.clear === 'function') { - await api.clear(Object.assign({ updateStats: true }, options || {})); - return true; - } - return replace([], options || {}); - } - - global.PracticeStore = Object.assign({}, global.PracticeStore || {}, { - list: list, - replace: replace, - save: save, - clear: clear - }); -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/resourceCore.js b/js/core/resourceCore.js index f5ccbf0a..faef27c0 100644 --- a/js/core/resourceCore.js +++ b/js/core/resourceCore.js @@ -3,8 +3,6 @@ const PATH_PROTOCOL_RE = /^(?:[a-z]+:)?\/\//i; const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/; - const PATH_MAP_STORAGE_PREFIX = 'exam_path_map__'; - const BASE_PREFIX_STORAGE_KEY = 'resource.basePrefix'; const PATH_FALLBACK_ORDER = ['map', 'fallback', 'raw', 'relative-up', 'relative-design']; const RAW_DEFAULT_PATH_MAP = { reading: { @@ -171,10 +169,6 @@ return result; } - function getPathMapStorageKey(key) { - return PATH_MAP_STORAGE_PREFIX + key; - } - function setActivePathMap(map) { const normalized = normalizePathMap(map); try { global.__activeLibraryPathMap = normalized; } catch (_) { } @@ -193,14 +187,10 @@ } async function loadPathMapForConfiguration(key) { - if (!key || !global.storage || typeof global.storage.get !== 'function') { - return clonePathMap(DEFAULT_PATH_MAP); - } + if (!key || !global.AppData || !global.AppData.library) return clonePathMap(DEFAULT_PATH_MAP); try { - const stored = await global.storage.get(getPathMapStorageKey(key)); - if (stored && typeof stored === 'object') { - return normalizePathMap(stored, DEFAULT_PATH_MAP); - } + const index = await global.AppData.library.getIndex(key); + return index.length ? derivePathMapFromIndex(index, DEFAULT_PATH_MAP) : clonePathMap(DEFAULT_PATH_MAP); } catch (error) { console.warn('[ResourceCore] 读取路径映射失败:', error); } @@ -217,14 +207,6 @@ ? normalizePathMap(overrideMap, fallback) : derivePathMapFromIndex(exams, fallback); - if (global.storage && typeof global.storage.set === 'function') { - try { - await global.storage.set(getPathMapStorageKey(key), derived); - } catch (error) { - console.warn('[ResourceCore] 写入路径映射失败:', error); - } - } - if (options.setActive) { setActivePathMap(derived); } @@ -232,25 +214,16 @@ } async function deletePathMapForConfiguration(key) { - if (!key || !global.storage || typeof global.storage.remove !== 'function') { - return false; - } - try { - await global.storage.remove(getPathMapStorageKey(key)); - return true; - } catch (error) { - console.warn('[ResourceCore] 删除路径映射失败:', error); - return false; - } + return Boolean(key); } async function refreshPathMap() { - if (!global.storage || typeof global.storage.get !== 'function') { + if (!global.AppData || !global.AppData.library) { return setActivePathMap(getPathMap()); } try { - const key = await global.storage.get('active_exam_index_key', 'exam_index'); - const next = await loadPathMapForConfiguration(key || 'exam_index'); + const key = await global.AppData.library.getActive(); + const next = await loadPathMapForConfiguration(key); return setActivePathMap(next); } catch (error) { console.warn('[ResourceCore] 刷新路径映射失败:', error); @@ -376,22 +349,13 @@ return null; } - function loadStoredBasePrefix() { - try { - return localStorage.getItem(BASE_PREFIX_STORAGE_KEY) || ''; - } catch (_) { - return ''; - } - } + let storedBasePrefix = ''; function storeBasePrefix(value) { - try { - if (value) { - localStorage.setItem(BASE_PREFIX_STORAGE_KEY, value); - } else { - localStorage.removeItem(BASE_PREFIX_STORAGE_KEY); - } - } catch (_) { } + storedBasePrefix = value || ''; + if (global.AppData && global.AppData.preferences) { + global.AppData.preferences.setResourceBasePrefix(storedBasePrefix).catch(() => {}); + } } function getBasePrefix() { @@ -400,7 +364,7 @@ return direct; } - const stored = normalizeBasePrefix(loadStoredBasePrefix()); + const stored = normalizeBasePrefix(storedBasePrefix); if (stored && stored !== './') { global.RESOURCE_BASE_PREFIX = stored; return stored; @@ -422,6 +386,13 @@ return normalized; } + if (global.AppData && global.AppData.preferences) { + global.AppData.preferences.getResourceBasePrefix().then((value) => { + storedBasePrefix = value || ''; + if (!global.RESOURCE_BASE_PREFIX && storedBasePrefix) global.RESOURCE_BASE_PREFIX = normalizeBasePrefix(storedBasePrefix); + }).catch(() => {}); + } + function resolveGeneratedReadingRuntimeUrl(exam, kind = 'html') { if (!exam || kind === 'pdf') { return ''; @@ -656,14 +627,12 @@ version: '0.6.2-fix', RAW_DEFAULT_PATH_MAP, DEFAULT_PATH_MAP, - PATH_MAP_STORAGE_PREFIX, PATH_FALLBACK_ORDER, clonePathMap, normalizePathRoot, mergeRootWithFallback, buildOverridePathMap, derivePathMapFromIndex, - getPathMapStorageKey, getPathMap, setActivePathMap, loadPathMapForConfiguration, diff --git a/js/core/scoreStorage.js b/js/core/scoreStorage.js deleted file mode 100644 index 314c9e5f..00000000 --- a/js/core/scoreStorage.js +++ /dev/null @@ -1,1876 +0,0 @@ -/** - * ScoreStorage — façade over PracticeRecordAPI / PracticeCore. - * No independent practice write path: saves must go through PracticeRecordAPI. - * Kept for PracticeRecorder UI helpers, stats/list adapters, and backup helpers - * during the post-data-layer transition (see Sprint B/C thinning). - */ -class ScoreStorage { - constructor(options = {}) { - this.repositories = options.repositories || window.dataRepositories; - if (!this.repositories) { - throw new Error('数据仓库未初始化,无法构建 ScoreStorage'); - } - - this.initializationError = null; - this.initializing = true; - - this.storageKeys = { - practiceRecords: 'practice_records', - userStats: 'user_stats', - storageVersion: 'storage_version', - backupData: 'manual_backups' - }; - - this.currentVersion = '0.6.2-fix'; - this.maxRecords = 1000; - this.storage = this.createStorageAdapter(); - if (typeof window !== 'undefined') { - window.scoreStorage = this; - } - - this.ready = this.initialize() - .catch((error) => { - this.initializationError = error; - return Promise.reject(error); - }) - .finally(() => { - this.initializing = false; - }); - } - - async ensureReady(options = {}) { - const { allowDuringInit = false } = options; - if (this.initializationError) { - throw this.initializationError; - } - if (this.initializing && allowDuringInit) { - return; - } - if (this.ready) { - await this.ready; - } - } - - getPracticeRecordAPI(requiredMethods = []) { - const api = window.PracticeRecordAPI; - if (!api || typeof api !== 'object') { - throw new Error('ScoreStorage: PracticeRecordAPI not ready'); - } - (Array.isArray(requiredMethods) ? requiredMethods : [requiredMethods]) - .filter(Boolean) - .forEach((methodName) => { - if (typeof api[methodName] !== 'function') { - throw new Error(`ScoreStorage: PracticeRecordAPI.${methodName} not ready`); - } - }); - return api; - } - - async listPracticeRecordsCanonical() { - const api = this.getPracticeRecordAPI(['list']); - const records = await api.list(); - return Array.isArray(records) ? records : []; - } - - async replacePracticeRecordsCanonical(records, options = {}) { - const finalRecords = Array.isArray(records) ? records : []; - const api = this.getPracticeRecordAPI(['replace']); - await api.replace(finalRecords, Object.assign({ - currentVersion: this.currentVersion, - maxRecords: this.maxRecords - }, options || {})); - return true; - } - - normalizePracticeType(rawType) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.normalizePracticeType === 'function') { - return coreContracts.normalizePracticeType(rawType); - } - if (!rawType) return null; - const normalized = String(rawType).toLowerCase(); - if (normalized.includes('listen')) return 'listening'; - if (normalized.includes('read')) return 'reading'; - return null; - } - - inferPracticeType(recordData = {}) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.inferPracticeType === 'function') { - return coreContracts.inferPracticeType(recordData); - } - const metadata = recordData.metadata || {}; - const normalized = this.normalizePracticeType( - recordData.type - || metadata.type - || metadata.examType - || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null) - ); - return normalized || 'reading'; - } - - resolveRecordDate(recordData = {}, now = new Date().toISOString()) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.resolveRecordDate === 'function') { - return coreContracts.resolveRecordDate(recordData, now); - } - const candidates = [ - recordData.metadata?.date, - recordData.date, - recordData.endTime, - recordData.completedAt, - recordData.startTime, - recordData.timestamp, - now - ]; - for (const value of candidates) { - if (!value) continue; - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return now; - } - - inferExamId(recordData = {}) { - if (!recordData || typeof recordData !== 'object') { - return null; - } - if (recordData.examId) { - return recordData.examId; - } - if (recordData.metadata?.examId) { - return recordData.metadata.examId; - } - if (Array.isArray(recordData.suiteEntries)) { - const suiteExam = recordData.suiteEntries.find(entry => entry && entry.examId); - if (suiteExam) { - return suiteExam.examId; - } - } - const recordId = recordData.id; - if (typeof recordId === 'string') { - const match = recordId.match(/^record_([^_]+)_/); - if (match && match[1]) { - return match[1]; - } - } - return null; - } - - buildMetadata(recordData = {}, type) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.buildMetadata === 'function') { - return coreContracts.buildMetadata(recordData, type); - } - const metadata = { ...(recordData.metadata || {}) }; - const examId = recordData.examId; - const fallbackTitle = recordData.title || recordData.examTitle || examId || 'Unknown Exam'; - const fallbackCategory = recordData.category || 'Unknown'; - const fallbackFrequency = recordData.frequency || 'unknown'; - - metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle; - metadata.category = metadata.category || fallbackCategory; - metadata.frequency = metadata.frequency || fallbackFrequency; - metadata.type = type; - metadata.examType = metadata.examType || type; - - return metadata; - } - - ensureNumber(value, fallback = 0) { - const num = Number(value); - return Number.isFinite(num) ? num : fallback; - } - - deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) { - const candidates = [ - recordData.totalQuestions, - recordData.questionCount, - recordData.scoreInfo?.total, - recordData.scoreInfo?.totalQuestions, - recordData.realData?.scoreInfo?.totalQuestions, - recordData.realData?.scoreInfo?.total - ]; - for (const candidate of candidates) { - const num = Number(candidate); - if (Number.isFinite(num) && num >= 0) { - return num; - } - } - - if (Array.isArray(recordData.answers)) { - return recordData.answers.length; - } - if (Array.isArray(recordData.answerList)) { - return recordData.answerList.length; - } - - const detailSources = [ - recordData.answerDetails, - recordData.scoreInfo?.details, - recordData.realData?.scoreInfo?.details - ]; - for (const details of detailSources) { - if (details && typeof details === 'object') { - return Object.keys(details).length; - } - } - return fallbackLength || 0; - } - - deriveCorrectAnswerCount(recordData = {}, answers = []) { - const numericCandidates = [ - recordData.correctAnswers, - recordData.correct, - recordData.score, - recordData.scoreInfo?.correct, - recordData.scoreInfo?.score, - recordData.realData?.scoreInfo?.correct, - recordData.realData?.scoreInfo?.score - ]; - for (const candidate of numericCandidates) { - const num = Number(candidate); - if (Number.isFinite(num) && num >= 0) { - return num; - } - } - - if ( - recordData.correctAnswers && - typeof recordData.correctAnswers === 'object' && - !Array.isArray(recordData.correctAnswers) - ) { - let hasBooleanFlag = false; - const correctCount = Object.values(recordData.correctAnswers).reduce((count, value) => { - if (typeof value === 'boolean') { - hasBooleanFlag = true; - return value ? count + 1 : count; - } - if (value && typeof value === 'object') { - const flag = value.isCorrect ?? value.correct; - if (typeof flag === 'boolean') { - hasBooleanFlag = true; - return flag ? count + 1 : count; - } - } - return count; - }, 0); - if (hasBooleanFlag) { - return correctCount; - } - } - - if (Array.isArray(answers) && answers.length > 0) { - const computed = answers.reduce((sum, answer) => { - if (!answer || typeof answer !== 'object') { - return sum; - } - if (answer.correct === true || answer.isCorrect === true) { - return sum + 1; - } - return sum; - }, 0); - if (computed > 0) { - return computed; - } - } - - const detailSources = [ - recordData.answerDetails, - recordData.scoreInfo?.details, - recordData.realData?.scoreInfo?.details - ]; - for (const details of detailSources) { - if (!details || typeof details !== 'object') { - continue; - } - let hasFlag = false; - let correct = 0; - Object.values(details).forEach(detail => { - if (!detail || typeof detail !== 'object') { - return; - } - if (detail.isCorrect === true || detail.correct === true) { - correct += 1; - } - hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean'; - }); - if (hasFlag) { - return correct; - } - } - const answerMap = {}; - if (Array.isArray(answers)) { - answers.forEach((answer) => { - if (!answer || typeof answer !== 'object') { - return; - } - const key = answer.questionId || answer.id || answer.key; - if (key && answer.answer != null) { - answerMap[this.normalizeAnswerMapKey(key)] = answer.answer; - } - }); - } else if (this.isPlainObject(answers)) { - Object.entries(answers).forEach(([key, value]) => { - const normalizedKey = this.normalizeAnswerMapKey(key); - if (normalizedKey && value != null) { - answerMap[normalizedKey] = value; - } - }); - } - const correctMap = this.resolveCorrectAnswerMap(recordData); - if (Object.keys(answerMap).length > 0 && Object.keys(correctMap).length > 0) { - return Object.keys(answerMap).reduce((count, key) => { - if (!Object.prototype.hasOwnProperty.call(correctMap, key)) { - return count; - } - return this.compareAnswerValues(answerMap[key], correctMap[key]) ? count + 1 : count; - }, 0); - } - return 0; - } - - compareAnswerValues(userAnswer, correctAnswer) { - if (userAnswer == null || correctAnswer == null) { - return false; - } - const matchCore = window.AnswerMatchCore; - if (matchCore && typeof matchCore.compareAnswers === 'function') { - return matchCore.compareAnswers(userAnswer, correctAnswer) === true; - } - return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase(); - } - - getDateOnlyIso(value) { - if (!value) return null; - if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { - return value; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return null; - } - const year = parsed.getFullYear(); - const month = String(parsed.getMonth() + 1).padStart(2, '0'); - const day = String(parsed.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - } - - getLocalDayStart(value) { - if (!value) return null; - if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { - const [year, month, day] = value.split('-').map(part => Number(part)); - if ([year, month, day].some(num => Number.isNaN(num))) { - return null; - } - return new Date(year, month - 1, day).getTime(); - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return null; - } - return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()).getTime(); - } - - createStorageAdapter() { - const metaRepo = this.repositories.meta; - const backupRepo = this.repositories.backups; - const keys = this.storageKeys; - const self = this; - - return { - async get(key, defaultValue = null) { - switch (key) { - case keys.practiceRecords: { - const api = self.getPracticeRecordAPI(['list']); - const records = await api.list(); - return Array.isArray(records) ? records : []; - } - case keys.userStats: { - const fallback = defaultValue !== null && defaultValue !== undefined ? defaultValue : self.getDefaultUserStats(); - const api = self.getPracticeRecordAPI(['readStats']); - return await api.readStats({ fallback }); - } - case keys.storageVersion: - return await metaRepo.get('storage_version', defaultValue); - case keys.backupData: - case 'manual_backups': - return await backupRepo.list(); - default: - return await metaRepo.get(key, defaultValue); - } - }, - async set(key, value) { - switch (key) { - case keys.practiceRecords: { - throw new Error('ScoreStorage.storage.set(practice_records) is disabled; use PracticeRecordAPI.replace'); - } - case keys.userStats: { - throw new Error('ScoreStorage.storage.set(user_stats) is disabled; use PracticeRecordAPI.writeStats'); - } - case keys.storageVersion: - await metaRepo.set('storage_version', value); - return true; - case keys.backupData: - case 'manual_backups': - await backupRepo.saveAll(Array.isArray(value) ? value : []); - return true; - default: - await metaRepo.set(key, value); - return true; - } - }, - async remove(key) { - switch (key) { - case keys.practiceRecords: { - throw new Error('ScoreStorage.storage.remove(practice_records) is disabled; use PracticeRecordAPI.clear'); - } - case keys.userStats: { - throw new Error('ScoreStorage.storage.remove(user_stats) is disabled; use PracticeRecordAPI.resetStats'); - } - case keys.storageVersion: - await metaRepo.remove('storage_version'); - return true; - case keys.backupData: - case 'manual_backups': - await backupRepo.clear(); - return true; - default: - await metaRepo.remove(key); - return true; - } - } - }; - } - - /** - * 初始化存储系统 - */ - async initialize() { - try { - console.log('ScoreStorage initialized'); - - // 检查存储版本并迁移数据 - await this.checkStorageVersion(); - - // 初始化数据结构 - await this.initializeDataStructures(); - - // Legacy migration happens at PersistentStore bootstrap, not in runtime services. - - // 暂时禁用清理过期数据,避免误删新记录 - // await this.cleanupExpiredData(); - } catch (error) { - this.initializationError = error; - console.error('[ScoreStorage] 初始化失败', error); - throw error; - } - } - - /** - * 检查存储版本 - */ - async checkStorageVersion() { - const normalizeVersion = v => { - if (v === undefined || v === null) return ''; - const s = String(v).trim(); - return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s; - }; - const storedVersionRaw = await this.storage.get(this.storageKeys.storageVersion); - const storedVersion = normalizeVersion(storedVersionRaw); - const current = normalizeVersion(this.currentVersion); - if (!storedVersion) { - await this.storage.set(this.storageKeys.storageVersion, current); - console.log('Storage version initialized:', current); - return; - } - if (storedVersion !== current) { - await this.migrateData(storedVersion, current); - } else { - console.log('[ScoreStorage] 版本匹配,跳过迁移'); - } - } - - /** - * 数据迁移 - */ - async migrateData(fromVersion, toVersion) { - if (String(fromVersion) === String(toVersion)) { - console.log('[ScoreStorage] migrateData skipped: same version'); - return; - } - console.log(`Migrating data from ${fromVersion} to ${toVersion}`); - - try { - // 备份当前数据 - await this.createBackup('migration_backup', { allowDuringInit: true }); - - // 根据版本执行相应的迁移逻辑 - if (fromVersion < '1.0.0') { - await this.migrateToV1(); - } - - // 更新版本号 - await this.storage.set(this.storageKeys.storageVersion, toVersion); - console.log('Data migration completed successfully'); - - } catch (error) { - console.error('Data migration failed:', error); - // 恢复备份数据 - try { - await this.restoreBackup('migration_backup', { allowDuringInit: true }); - } catch (restoreError) { - console.error('Failed to restore backup:', restoreError); - } - } - } - - /** - * 迁移到版本1.0.0 - */ - async migrateToV1() { - // 标准化练习记录格式 - const records = await this.listPracticeRecordsCanonical(); - const standardizedRecords = records.map(record => this.standardizeRecord(record)); - await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: true }); - } - - /** - * 初始化数据结构 - */ - async initializeDataStructures() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() }); - console.log('[ScoreStorage] 用户统计由 PracticeRecordAPI 管理'); - return; - } - console.warn('[ScoreStorage] PracticeRecordAPI.readStats unavailable, skip stats initialization'); - } - - /** - * 获取默认用户统计 - */ - getDefaultUserStats() { - if (window.ExamData && typeof window.ExamData.createDefaultUserStats === 'function') { - return window.ExamData.createDefaultUserStats(); - } - const now = new Date().toISOString(); - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [], - createdAt: now, - updatedAt: now - }; - } - - /** - * 保存练习记录 - */ - async savePracticeRecord(recordData) { - try { - await this.ensureReady(); - // 标准化记录格式 - const standardizedRecord = this.standardizeRecord(recordData); - - // 验证记录数据 - this.validateRecord(standardizedRecord); - - const practiceRecordApi = window.PracticeRecordAPI; - if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') { - try { - const savedRecord = await practiceRecordApi.saveRecord(standardizedRecord, { - currentVersion: this.currentVersion, - maxRecords: this.maxRecords, - updateStats: true - }); - console.log('Practice record saved:', savedRecord.id); - return savedRecord; - } catch (apiError) { - console.warn('[ScoreStorage] PracticeRecordAPI 保存失败:', apiError); - throw apiError; - } - } - - throw new Error('ScoreStorage.savePracticeRecord: unified store not ready'); - - } catch (error) { - console.error('Failed to save practice record:', error); - throw error; - } - } - - normalizeLegacyRecord(record) { - if (!record || typeof record !== 'object') { - return record; - } - const patched = Object.assign({}, record); - if (Array.isArray(record.suiteEntries)) { - patched.suiteEntries = record.suiteEntries.map(entry => this.clonePlainObject(entry)).filter(Boolean); - } - if (record.suiteMode != null) { - patched.suiteMode = Boolean(record.suiteMode); - } - if (record.suiteSessionId) { - patched.suiteSessionId = record.suiteSessionId; - } - if (record.frequency) { - patched.frequency = record.frequency; - } - const inferredType = this.inferPracticeType(patched); - if (!patched.type) { - patched.type = inferredType; - } - const normalizedMetadata = this.buildMetadata( - Object.assign({}, patched, { metadata: patched.metadata || {} }), - patched.type - ); - patched.metadata = normalizedMetadata; - const normalizedAnswers = this.standardizeAnswers(patched.answers || patched.answerList || []); - patched.answers = normalizedAnswers; - patched.answerList = normalizedAnswers; - const answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - const comparisonSource = patched.answerComparison || patched.realData?.answerComparison || null; - const detailSource = patched.scoreInfo?.details - || patched.realData?.scoreInfo?.details - || patched.answerDetails - || null; - const normalizedCorrectMap = this.resolveCorrectAnswerMap(patched, comparisonSource, detailSource); - patched.correctAnswerMap = normalizedCorrectMap || {}; - if (!patched.answerDetails || typeof patched.answerDetails !== 'object') { - patched.answerDetails = this.buildAnswerDetailsFromMaps(answerMap, patched.correctAnswerMap); - } - const derivedTotals = this.deriveTotalQuestionCount(patched, normalizedAnswers.length); - const derivedCorrect = this.deriveCorrectAnswerCount(patched, normalizedAnswers); - patched.totalQuestions = this.ensureNumber(patched.totalQuestions, derivedTotals); - patched.correctAnswers = this.ensureNumber(patched.correctAnswers, derivedCorrect); - patched.score = this.ensureNumber(patched.score, patched.correctAnswers); - patched.accuracy = this.ensureNumber( - patched.accuracy, - patched.totalQuestions > 0 ? patched.correctAnswers / patched.totalQuestions : 0 - ); - if (!patched.startTime) { - patched.startTime = patched.date || patched.endTime || new Date().toISOString(); - } - if (!patched.endTime) { - patched.endTime = patched.date || patched.startTime; - } - if (!patched.status) { - patched.status = 'completed'; - } - if (!patched.scoreInfo) { - patched.scoreInfo = {}; - } - if (!patched.scoreInfo.details && patched.answerDetails) { - patched.scoreInfo.details = patched.answerDetails; - } - if (patched.realData) { - patched.realData = Object.assign({}, patched.realData, { - answers: patched.realData.answers || answerMap, - correctAnswers: patched.correctAnswerMap, - correctAnswerMap: patched.correctAnswerMap, - scoreInfo: Object.assign({}, patched.realData.scoreInfo || {}, { - details: patched.realData.scoreInfo?.details || patched.answerDetails || null - }) - }); - } - return patched; - } - - needsRecordSanitization(record) { - if (!record || typeof record !== 'object') { - return true; - } - if (!record.type || !record.metadata || !record.metadata.type) { - return true; - } - const numericFields = ['score', 'totalQuestions', 'correctAnswers', 'accuracy', 'duration']; - return numericFields.some((field) => { - if (!Object.prototype.hasOwnProperty.call(record, field)) { - return false; - } - return typeof record[field] !== 'number' || Number.isNaN(record[field]); - }); - } - - /** - * 标准化记录格式 - */ - standardizeRecord(recordData) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.standardizeRecord === 'function') { - return coreContracts.standardizeRecord(recordData, { - currentVersion: this.currentVersion, - generateRecordId: () => this.generateRecordId() - }); - } - const now = new Date().toISOString(); - const type = this.inferPracticeType(recordData); - const recordDate = this.resolveRecordDate(recordData, now); - const resolvedExamId = this.inferExamId(recordData); - const metadata = this.buildMetadata( - Object.assign({}, recordData, { examId: resolvedExamId }), - type - ); - const comparisonSource = recordData.answerComparison - || recordData.realData?.answerComparison - || null; - const normalizedAnswers = this.standardizeAnswers(recordData.answers || recordData.answerList || []); - let answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - // 如果 answers 为空,尝试从 answerComparison 补齐 userAnswer - if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) { - const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer'); - if (Object.keys(fromComparison).length > 0) { - answerMap = fromComparison; - } - } - const suiteSessionId = recordData.suiteSessionId - || recordData.metadata?.suiteSessionId - || null; - if (suiteSessionId && !metadata.suiteSessionId) { - metadata.suiteSessionId = suiteSessionId; - } - const frequency = recordData.frequency || metadata.frequency || null; - if (frequency && !metadata.frequency) { - metadata.frequency = frequency; - } - const normalizedCorrectMap = this.resolveCorrectAnswerMap(recordData, comparisonSource); - const derivedTotalQuestions = this.deriveTotalQuestionCount(recordData, normalizedAnswers.length); - const derivedCorrectAnswers = this.deriveCorrectAnswerCount(recordData, normalizedAnswers); - const totalQuestions = this.ensureNumber(recordData.totalQuestions, derivedTotalQuestions); - const correctAnswers = this.ensureNumber(recordData.correctAnswers, derivedCorrectAnswers); - let accuracy = this.ensureNumber( - recordData.accuracy, - totalQuestions > 0 ? correctAnswers / totalQuestions : 0 - ); - if (accuracy > 1 && accuracy <= 100) { - accuracy = accuracy / 100; // 容错百分比形式 - } - if (!Number.isFinite(accuracy) || accuracy < 0) { - accuracy = 0; - } else if (accuracy > 1) { - accuracy = 1; - } - const detailSource = recordData.answerDetails - || recordData.scoreInfo?.details - || recordData.realData?.scoreInfo?.details - || (comparisonSource ? this.convertComparisonToDetails(comparisonSource) : null) - || this.buildAnswerDetailsFromMaps(answerMap, normalizedCorrectMap); - - const startTime = recordData.startTime && !Number.isNaN(new Date(recordData.startTime).getTime()) - ? new Date(recordData.startTime).toISOString() - : recordDate; - const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime()) - ? new Date(recordData.endTime).toISOString() - : recordDate; - const resolvedTitle = recordData.title - || metadata.examTitle - || metadata.title - || recordData.examTitle - || recordData.examId - || '未命名练习'; - const normalizedSuiteEntries = this.standardizeSuiteEntries(recordData.suiteEntries || []); - const normalizedComparison = comparisonSource && typeof comparisonSource === 'object' - ? this.clonePlainObject(comparisonSource) - : null; - - return { - // 基础信息 - id: recordData.id || this.generateRecordId(), - examId: resolvedExamId, - sessionId: recordData.sessionId, - title: resolvedTitle, - type, - - // 时间信息 - startTime, - endTime, - duration: this.ensureNumber(recordData.duration, 0), - date: recordDate, - - // 成绩信息 - status: recordData.status || 'completed', - score: this.ensureNumber(recordData.score, correctAnswers), - totalQuestions, - correctAnswers, - accuracy, - - // 答题详情 - answers: normalizedAnswers, - answerDetails: detailSource || null, - correctAnswerMap: normalizedCorrectMap || {}, - questionTypePerformance: recordData.questionTypePerformance || {}, - - // 元数据 - metadata, - frequency: frequency || metadata.frequency || null, - suiteMode: Boolean(recordData.suiteMode || (frequency && frequency.toLowerCase() === 'suite')), - suiteSessionId, - suiteEntries: normalizedSuiteEntries, - scoreInfo: recordData.scoreInfo - ? Object.assign({}, recordData.scoreInfo, { - details: recordData.scoreInfo.details || detailSource || null - }) - : (detailSource ? { details: detailSource } : null), - realData: recordData.realData - ? Object.assign({}, recordData.realData, { - answers: recordData.realData.answers || answerMap, - correctAnswers: normalizedCorrectMap, - correctAnswerMap: normalizedCorrectMap, - scoreInfo: Object.assign({}, recordData.realData.scoreInfo || {}, { - details: recordData.realData.scoreInfo?.details || detailSource || null - }), - answerComparison: recordData.realData.answerComparison - ? this.clonePlainObject(recordData.realData.answerComparison) - : (normalizedComparison || null) - }) - : (normalizedComparison ? { answerComparison: normalizedComparison } : null), - answerComparison: normalizedComparison, - - // 系统信息 - version: this.currentVersion, - createdAt: recordData.createdAt || now, - updatedAt: now - }; - } - - /** - * 标准化答案格式 - */ - standardizeAnswers(answers) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.buildAnswerArray === 'function') { - return coreContracts.buildAnswerArray(answers); - } - if (!Array.isArray(answers)) { - if (answers && typeof answers === 'object') { - answers = Object.entries(answers).map(([questionId, value]) => ({ - questionId, - answer: value - })); - } else { - answers = []; - } - } - return answers.map((answer, index) => ({ - questionId: answer.questionId || `q${index + 1}`, - answer: answer.answer || '', - correctAnswer: answer.correctAnswer || '', - correct: Boolean(answer.correct), - timeSpent: answer.timeSpent || 0, - questionType: answer.questionType || 'unknown', - timestamp: answer.timestamp || new Date().toISOString() - })); - } - - clonePlainObject(value) { - if (value == null || typeof value !== 'object') { - return value ?? null; - } - if (Array.isArray(value)) { - return value.map(item => this.clonePlainObject(item)).filter(item => item !== undefined); - } - const clone = {}; - Object.keys(value).forEach((key) => { - const entry = value[key]; - clone[key] = (entry && typeof entry === 'object') - ? this.clonePlainObject(entry) - : entry; - }); - return clone; - } - - isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); - } - - normalizeAnswerMapKey(key) { - if (key == null) { - return ''; - } - let normalizedKey = String(key).trim(); - if (!normalizedKey) { - return ''; - } - if (/^\d+$/.test(normalizedKey)) { - normalizedKey = `q${normalizedKey}`; - } else if (normalizedKey.startsWith('question')) { - normalizedKey = normalizedKey.replace('question', 'q'); - } - return normalizedKey; - } - - mergeAnswerMaps(...sources) { - const merged = {}; - sources.forEach((source) => { - if (!this.isPlainObject(source)) { - return; - } - Object.entries(source).forEach(([key, value]) => { - const normalizedKey = this.normalizeAnswerMapKey(key); - if (!normalizedKey || Object.prototype.hasOwnProperty.call(merged, normalizedKey)) { - return; - } - if (value == null || String(value).trim() === '') { - return; - } - merged[normalizedKey] = value; - }); - }); - return merged; - } - - convertComparisonToMap(comparison, key = 'correctAnswer') { - if (!comparison || typeof comparison !== 'object') { - return {}; - } - const map = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (!entry || typeof entry !== 'object') return; - const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.user); - if (value != null && String(value).trim() !== '') { - map[questionId] = value; - } - }); - return map; - } - - resolveCorrectAnswerMap(recordData = {}, comparisonSource = null, detailSource = null) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.resolveRecordCorrectAnswerMap === 'function') { - return coreContracts.resolveRecordCorrectAnswerMap(recordData, { - comparison: comparisonSource, - detailSources: detailSource ? [detailSource] : [] - }); - } - const realData = this.isPlainObject(recordData.realData) ? recordData.realData : {}; - const effectiveComparison = comparisonSource || recordData.answerComparison || realData.answerComparison || null; - return this.mergeAnswerMaps( - recordData.correctAnswerMap, - realData.correctAnswerMap, - recordData.correctAnswers, - realData.correctAnswers, - effectiveComparison ? this.convertComparisonToMap(effectiveComparison, 'correctAnswer') : null, - recordData.answerDetails ? this.deriveCorrectMapFromDetails(recordData.answerDetails) : null, - detailSource ? this.deriveCorrectMapFromDetails(detailSource) : null, - recordData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(recordData.scoreInfo.details) : null, - realData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(realData.scoreInfo.details) : null - ); - } - - convertComparisonToDetails(comparison) { - if (!comparison || typeof comparison !== 'object') { - return null; - } - const details = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (!entry || typeof entry !== 'object') return; - details[questionId] = { - userAnswer: entry.userAnswer ?? entry.user ?? '', - correctAnswer: entry.correctAnswer ?? entry.correct ?? '', - isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null - }; - }); - return details; - } - - standardizeSuiteEntries(entries) { - if (!Array.isArray(entries)) { - return []; - } - return entries.map((entry, index) => { - if (!entry || typeof entry !== 'object') { - return null; - } - const normalizedAnswers = this.standardizeAnswers(entry.answers || entry.answerList || []); - const answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - const normalizedScoreInfo = entry.scoreInfo - ? Object.assign({}, entry.scoreInfo, { - details: entry.scoreInfo?.details - ? this.clonePlainObject(entry.scoreInfo.details) - : null - }) - : null; - const answerComparisonSource = entry.answerComparison - || normalizedScoreInfo?.details - || entry.rawData?.answerComparison - || null; - const normalizedCorrectMap = this.resolveCorrectAnswerMap( - entry, - answerComparisonSource, - normalizedScoreInfo?.details || entry.rawData?.scoreInfo?.details || null - ); - const highlights = Array.isArray(entry.highlights) - ? entry.highlights.slice() - : (Array.isArray(entry.rawData?.highlights) ? entry.rawData.highlights.slice() : []); - const scrollY = Number.isFinite(Number(entry.scrollY)) - ? Number(entry.scrollY) - : (Number.isFinite(Number(entry.rawData?.scrollY)) ? Number(entry.rawData.scrollY) : 0); - return { - examId: entry.examId || null, - title: entry.title || entry.examTitle || `套题第${index + 1}篇`, - category: entry.category || entry.metadata?.category || '套题', - duration: this.ensureNumber(entry.duration, 0), - scoreInfo: normalizedScoreInfo, - answers: answerMap, - correctAnswerMap: normalizedCorrectMap, - answerComparison: this.clonePlainObject(answerComparisonSource) || null, - metadata: entry.metadata ? Object.assign({}, entry.metadata) : {}, - highlights, - scrollY, - rawData: entry.rawData ? this.clonePlainObject(entry.rawData) : null - }; - }).filter(Boolean); - } - - deriveCorrectMapFromDetails(details) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.deriveCorrectMapFromDetails === 'function') { - return coreContracts.deriveCorrectMapFromDetails(details); - } - if (!details || typeof details !== 'object') { - return {}; - } - const map = {}; - Object.entries(details).forEach(([questionId, info]) => { - if (!info) { - return; - } - const correctAnswer = info.correctAnswer || info.answer || info.value; - if (correctAnswer != null) { - map[questionId] = (typeof correctAnswer === 'string') - ? correctAnswer.trim() - : String(correctAnswer); - } - }); - return map; - } - - buildAnswerDetailsFromMaps(answerMap = {}, correctMap = {}) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.buildAnswerDetails === 'function') { - return coreContracts.buildAnswerDetails(answerMap, correctMap); - } - const details = {}; - const keys = new Set([ - ...Object.keys(answerMap || {}), - ...Object.keys(correctMap || {}) - ]); - keys.forEach((questionId) => { - const userAnswer = answerMap && answerMap[questionId] ? String(answerMap[questionId]) : '-'; - const correctAnswer = correctMap && correctMap[questionId] ? String(correctMap[questionId]) : '-'; - let isCorrect = null; - if (correctAnswer !== '-') { - const matchCore = window.AnswerMatchCore; - isCorrect = matchCore && typeof matchCore.compareAnswers === 'function' - ? matchCore.compareAnswers(userAnswer, correctAnswer) === true - : userAnswer.toLowerCase() === correctAnswer.toLowerCase(); - } - details[questionId] = { - userAnswer, - correctAnswer, - isCorrect - }; - }); - return details; - } - - /** - * 验证记录数据 - */ - validateRecord(record) { - const requiredFields = ['id', 'examId', 'startTime', 'endTime']; - - for (const field of requiredFields) { - if (!record[field]) { - throw new Error(`Missing required field: ${field}`); - } - } - - // 验证时间格式 - if (new Date(record.startTime).toString() === 'Invalid Date') { - throw new Error('Invalid startTime format'); - } - - if (new Date(record.endTime).toString() === 'Invalid Date') { - throw new Error('Invalid endTime format'); - } - - // 验证数值范围 - record.accuracy = Math.max(0, Math.min(1, Number(record.accuracy) || 0)); - - record.duration = Number.isFinite(record.duration) && record.duration >= 0 - ? record.duration - : 0; - } - - /** - * 更新用户统计 - */ - async updateUserStats(practiceRecord, options = {}) { - const { allowDuringInit = false } = options; - await this.recalculateUserStats({ allowDuringInit }); - } - - applyRecordToStats(stats, practiceRecord) { - if (!stats || typeof stats !== 'object') { - return; - } - - const duration = Number(practiceRecord.duration) || 0; - const accuracy = Number(practiceRecord.accuracy) || 0; - const normalizedRecord = { ...practiceRecord, duration, accuracy }; - - stats.categoryStats = stats.categoryStats && typeof stats.categoryStats === 'object' ? stats.categoryStats : {}; - stats.questionTypeStats = stats.questionTypeStats && typeof stats.questionTypeStats === 'object' ? stats.questionTypeStats : {}; - - stats.totalPractices += 1; - stats.totalTimeSpent += duration; - - const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy; - stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0; - - this.updateCategoryStats(stats, normalizedRecord); - this.updateQuestionTypeStats(stats, normalizedRecord); - this.updateStreakDays(stats, normalizedRecord); - this.checkAchievements(stats, normalizedRecord); - - stats.updatedAt = new Date().toISOString(); - } - - /** - * 更新分类统计 - */ - updateCategoryStats(stats, practiceRecord) { - const category = practiceRecord?.metadata?.category; - if (!category) return; - - if (!stats.categoryStats[category]) { - stats.categoryStats[category] = { - practices: 0, - avgScore: 0, - timeSpent: 0, - bestScore: 0, - totalQuestions: 0, - correctAnswers: 0 - }; - } - - const catStats = stats.categoryStats[category]; - catStats.practices += 1; - catStats.timeSpent += practiceRecord.duration; - catStats.totalQuestions += practiceRecord.totalQuestions; - catStats.correctAnswers += practiceRecord.correctAnswers; - catStats.bestScore = Math.max(catStats.bestScore, practiceRecord.accuracy); - - // 重新计算平均分数 - const catTotalScore = (catStats.avgScore * (catStats.practices - 1)) + practiceRecord.accuracy; - catStats.avgScore = catTotalScore / catStats.practices; - } - - /** - * 更新题型统计 - */ - updateQuestionTypeStats(stats, practiceRecord) { - if (!practiceRecord.questionTypePerformance) return; - - Object.entries(practiceRecord.questionTypePerformance).forEach(([type, performance]) => { - if (!stats.questionTypeStats[type]) { - stats.questionTypeStats[type] = { - practices: 0, - accuracy: 0, - totalQuestions: 0, - correctAnswers: 0, - avgTimePerQuestion: 0 - }; - } - - const typeStats = stats.questionTypeStats[type]; - typeStats.practices += 1; - typeStats.totalQuestions += performance.total || 0; - typeStats.correctAnswers += performance.correct || 0; - - // 重新计算准确率 - typeStats.accuracy = typeStats.totalQuestions > 0 - ? typeStats.correctAnswers / typeStats.totalQuestions - : 0; - - // 计算平均每题用时 - if (performance.timeSpent && performance.total) { - const newAvgTime = performance.timeSpent / performance.total; - typeStats.avgTimePerQuestion = (typeStats.avgTimePerQuestion * (typeStats.practices - 1) + newAvgTime) / typeStats.practices; - } - }); - } - - /** - * 更新连续学习天数 - */ - updateStreakDays(stats, practiceRecord) { - const recordSource = practiceRecord.date || practiceRecord.endTime || practiceRecord.startTime; - const recordDay = this.getDateOnlyIso(recordSource); - if (!recordDay) return; - - const dayMs = 24 * 60 * 60 * 1000; - let practiceDays = Array.isArray(stats.practiceDays) ? stats.practiceDays.slice() : []; - - if (practiceDays.length === 0) { - const historicalStreak = Math.max(0, Math.round(this.ensureNumber(stats.streakDays, 0))); - const lastPracticeIso = this.getDateOnlyIso(stats.lastPracticeDate); - const lastPracticeStart = this.getLocalDayStart(lastPracticeIso); - - if (historicalStreak > 0 && lastPracticeIso && Number.isFinite(lastPracticeStart)) { - const migratedDays = []; - for (let offset = historicalStreak - 1; offset >= 0; offset -= 1) { - const timestamp = lastPracticeStart - (offset * dayMs); - const dayIso = this.getDateOnlyIso(timestamp); - if (dayIso) { - migratedDays.push(dayIso); - } - } - practiceDays = migratedDays; - } - } - - const uniqueDays = new Set(practiceDays); - uniqueDays.add(recordDay); - practiceDays = Array.from(uniqueDays); - - const validDays = practiceDays - .map(day => ({ day, start: this.getLocalDayStart(day) })) - .filter(item => item.start !== null) - .sort((a, b) => a.start - b.start); - - if (validDays.length === 0) { - stats.practiceDays = []; - stats.streakDays = 0; - stats.lastPracticeDate = null; - return; - } - - let currentStreak = 1; - - for (let index = 1; index < validDays.length; index += 1) { - const previous = validDays[index - 1]; - const current = validDays[index]; - const diff = Math.round((current.start - previous.start) / (1000 * 60 * 60 * 24)); - - if (diff === 1) { - currentStreak += 1; - } else if (diff > 1) { - currentStreak = 1; - } - } - - stats.practiceDays = validDays.map(item => item.day); - stats.streakDays = currentStreak; - stats.lastPracticeDate = validDays[validDays.length - 1].day; - } - - /** - * 检查成就 - */ - checkAchievements(stats, practiceRecord) { - const achievements = stats.achievements || []; - - // 首次练习成就 - if (stats.totalPractices === 1 && !achievements.includes('first-practice')) { - achievements.push('first-practice'); - } - - // 连续学习成就 - if (stats.streakDays >= 7 && !achievements.includes('week-streak')) { - achievements.push('week-streak'); - } - - if (stats.streakDays >= 30 && !achievements.includes('month-streak')) { - achievements.push('month-streak'); - } - - // 高分成就 - if (practiceRecord.accuracy >= 0.9 && !achievements.includes('high-scorer')) { - achievements.push('high-scorer'); - } - - // 分类掌握成就 - const category = practiceRecord.metadata.category; - if (category && stats.categoryStats[category]) { - const catStats = stats.categoryStats[category]; - if (catStats.practices >= 10 && catStats.avgScore >= 0.8) { - const achievementKey = `${category.toLowerCase()}-master`; - if (!achievements.includes(achievementKey)) { - achievements.push(achievementKey); - } - } - } - - stats.achievements = achievements; - } - - /** - * 获取练习记录 - */ - async getPracticeRecords(filters = {}) { - await this.ensureReady(); - const raw = await this.listPracticeRecordsCanonical(); - const base = Array.isArray(raw) ? raw : []; - // Normalize each record to ensure UI can rely on a stable shape - const records = base.map(r => this.normalizeRecordFields(r)); - - if (Object.keys(filters).length === 0) { - return records.sort((a, b) => new Date(b.startTime) - new Date(a.startTime)); - } - - return records.filter(record => { - // 按考试ID筛选 - if (filters.examId && record.examId !== filters.examId) return false; - - // 按分类筛选 - if (filters.category && record.metadata.category !== filters.category) return false; - - // 按时间范围筛选 - if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false; - if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false; - - // 按准确率筛选 - if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false; - if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false; - - // 按状态筛选 - if (filters.status && record.status !== filters.status) return false; - - return true; - }).sort((a, b) => new Date(b.startTime) - new Date(a.startTime)); - } - - /** - * 获取用户统计 - */ - async getUserStats(options = {}) { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - const api = this.getPracticeRecordAPI(['readStats']); - return await api.readStats({ fallback: this.getDefaultUserStats() }); - } - - /** - * 重新计算用户统计 - */ - async recalculateUserStats(options = {}) { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - const api = this.getPracticeRecordAPI(['recalculateStats']); - const stats = await api.recalculateStats(); - console.log('User stats recalculated through PracticeRecordAPI'); - return stats; - } - - /** - * 将不同来源/版本的记录统一为稳定字段,以便 UI/统计可靠工作 - * 不修改存储中的原始对象,仅在返回路径做兼容填充 - */ - normalizeRecordFields(record) { - try { - const r = { ...(record || {}) }; - - // metadata 兜底 - r.metadata = { - examTitle: (r.metadata && r.metadata.examTitle) || r.title || r.examTitle || r.examId || '', - category: (r.metadata && r.metadata.category) || r.category || '', - frequency: (r.metadata && r.metadata.frequency) || r.frequency || '', - ...(r.metadata || {}) - }; - - // 时间字段归一 - const rd = r.realData || {}; - if (!r.startTime) { - if (typeof rd.startTime === 'number') { - r.startTime = new Date(rd.startTime).toISOString(); - } else if (rd.startTime) { - r.startTime = new Date(rd.startTime).toISOString(); - } else if (r.date) { - r.startTime = new Date(r.date).toISOString(); - } - } - if (!r.endTime) { - if (typeof rd.endTime === 'number') { - r.endTime = new Date(rd.endTime).toISOString(); - } else if (rd.endTime) { - r.endTime = new Date(rd.endTime).toISOString(); - } else if (r.startTime && (r.duration || rd.duration)) { - const base = new Date(r.startTime).getTime(); - const seconds = (Number(r.duration || rd.duration) || 0); - r.endTime = new Date(base + seconds * 1000).toISOString(); - } - } - - // 用时归一(秒): consider multiple possible fields; prefer positive seconds - if (!(typeof r.duration === 'number' && isFinite(r.duration) && r.duration > 0)) { - const sInfo = r.scoreInfo || rd.scoreInfo || {}; - const candidates = [ - r.duration, rd.duration, r.durationSeconds, r.duration_seconds, - r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent, - rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent, - sInfo.duration, sInfo.timeSpent - ]; - let picked; - for (const v of candidates) { - const n = Number(v); - if (Number.isFinite(n) && n > 0) { picked = n; break; } - } - if (picked !== undefined) { - r.duration = Math.floor(picked); - } else if (r.startTime && r.endTime) { - r.duration = Math.max(0, Math.floor((new Date(r.endTime) - new Date(r.startTime)) / 1000)); - } else if (Array.isArray(rd.interactions) && rd.interactions.length) { - // Derive from interactions timestamp span - try { - const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n)); - if (ts.length) { - const span = Math.max(...ts) - Math.min(...ts); - if (Number.isFinite(span) && span > 0) r.duration = Math.floor(span / 1000); - } - } catch(_) {} - } else { - r.duration = 0; - } - } - - // scoreInfo 归一 - const sInfo = r.scoreInfo || rd.scoreInfo || {}; - if (!r.scoreInfo && (rd.scoreInfo || r.answerComparison)) { - r.scoreInfo = sInfo; - } - - // answers 归一 - if (!r.answers && rd.answers) { - r.answers = rd.answers; - } - if (Array.isArray(r.answers)) { - const map = {}; - r.answers.forEach((entry, idx) => { - if (!entry) return; - const key = entry.questionId || `q${idx + 1}`; - map[key] = entry.answer || entry.userAnswer || ''; - }); - r.answerList = r.answers.slice(); - r.answers = map; - } - if (Array.isArray(rd.answers)) { - const rdMap = {}; - rd.answers.forEach((entry, idx) => { - if (!entry) return; - const key = entry.questionId || `q${idx + 1}`; - rdMap[key] = entry.answer || entry.userAnswer || ''; - }); - rd.answers = rdMap; - } - const comparisonSource = r.answerComparison || rd.answerComparison || null; - if ((!r.answers || Object.keys(r.answers).length === 0) && comparisonSource) { - const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer'); - if (Object.keys(fromComparison).length > 0) { - r.answers = fromComparison; - } - } - const normalizedCorrectMap = this.resolveCorrectAnswerMap( - r, - comparisonSource, - r.answerDetails || r.scoreInfo?.details || rd.scoreInfo?.details || null - ); - if (Object.keys(normalizedCorrectMap).length > 0) { - r.correctAnswerMap = normalizedCorrectMap; - } - if (!r.answerDetails) { - if (comparisonSource) { - r.answerDetails = this.convertComparisonToDetails(comparisonSource); - } - if (!r.answerDetails) { - r.answerDetails = r.scoreInfo?.details || this.buildAnswerDetailsFromMaps(r.answers, r.correctAnswerMap); - } - } - - // 正确/总题数归一 - const derivedCorrect = (typeof r.correctAnswers === 'number') ? r.correctAnswers - : (typeof r.score === 'number' ? r.score - : (typeof sInfo.correct === 'number' - ? sInfo.correct - : this.deriveCorrectAnswerCount(r, r.answers || []))); - - const derivedTotal = (typeof r.totalQuestions === 'number') ? r.totalQuestions - : (typeof sInfo.total === 'number' ? sInfo.total - : (r.realData && typeof r.realData.totalQuestions === 'number' ? r.realData.totalQuestions - : (r.answers ? Object.keys(r.answers).length - : (rd.answers ? Object.keys(rd.answers || {}).length : null)))); - - if (typeof r.correctAnswers !== 'number' && derivedCorrect != null) { - r.correctAnswers = derivedCorrect; - } - if (typeof r.totalQuestions !== 'number' && derivedTotal != null) { - r.totalQuestions = derivedTotal; - } - if (r.realData && typeof r.realData === 'object') { - r.realData.correctAnswers = r.correctAnswerMap || {}; - r.realData.correctAnswerMap = r.correctAnswerMap || {}; - } - - // 准确率/百分比归一 - let acc = (typeof r.accuracy === 'number') ? r.accuracy - : (typeof sInfo.accuracy === 'number' ? sInfo.accuracy : null); - if (acc == null) { - if (typeof r.correctAnswers === 'number' && typeof r.totalQuestions === 'number' && r.totalQuestions > 0) { - acc = r.correctAnswers / r.totalQuestions; - } else { - acc = 0; - } - } - r.accuracy = acc; - - if (typeof r.percentage !== 'number' || isNaN(r.percentage)) { - if (typeof sInfo.percentage === 'number') { - r.percentage = sInfo.percentage; - } else { - r.percentage = Math.round(acc * 100); - } - } - - // 状态兜底 - if (!r.status) r.status = 'completed'; - - return r; - } catch (e) { - try { console.warn('[ScoreStorage] normalizeRecordFields failed:', e); } catch(_) {} - return record; - } - } - - /** - * 创建数据备份 - 统一走 BackupAPI → BackupRepository - */ - async createBackup(backupName = null, options = {}) { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - - if (window.BackupAPI && typeof window.BackupAPI.create === 'function') { - const practiceRecords = await this.listPracticeRecordsCanonical(); - const userStats = await this.getUserStats({ allowDuringInit }); - const storageVersion = await this.storage.get(this.storageKeys.storageVersion); - const examIndex = await this.storage.get('exam_index', []); - const backupId = await window.BackupAPI.create({ - id: backupName || undefined, - type: 'score_storage', - data: { - practice_records: practiceRecords, - user_stats: userStats, - exam_index: Array.isArray(examIndex) ? examIndex : [], - storage_version: storageVersion - } - }); - console.log('[ScoreStorage] Backup created via BackupAPI:', backupId); - return backupId; - } - - // Fallback: DataBackupManager path (still ends at BackupAPI if loaded) - if (window.DataBackupManager) { - const backupManager = new DataBackupManager(); - const backupId = await backupManager.createBackup( - backupName || `score_backup_${Date.now()}`, - 'score_storage' - ); - console.log('[ScoreStorage] Backup created via DataBackupManager:', backupId); - return backupId; - } - - console.warn('[ScoreStorage] BackupAPI not available, skipping backup'); - return null; - } - - /** - * 恢复数据备份 - 统一走 BackupAPI - */ - async restoreBackup(backupId, options = {}) { - try { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - - if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') { - const result = await window.BackupAPI.restore(backupId); - console.log('[ScoreStorage] Backup restored via BackupAPI:', backupId); - return result.backup; - } - - // Fallback dual-schema restore when BackupAPI missing - const backups = await this.storage.get('manual_backups', []); - const backup = backups.find(b => b.id === backupId); - - if (!backup) { - throw new Error(`Backup not found: ${backupId}`); - } - - if (backup.data) { - const data = backup.data; - const records = Array.isArray(data.practiceRecords) - ? data.practiceRecords - : (Array.isArray(data.practice_records) ? data.practice_records : []); - const stats = (data.userStats && typeof data.userStats === 'object') - ? data.userStats - : ((data.user_stats && typeof data.user_stats === 'object') ? data.user_stats : null); - const hasStats = Boolean(stats); - await this.replacePracticeRecordsCanonical(records, { updateStats: !hasStats }); - if (hasStats) { - const api = this.getPracticeRecordAPI(['resetStats']); - await api.resetStats(stats); - } - if (data.storageVersion || data.storage_version) { - await this.storage.set(this.storageKeys.storageVersion, data.storageVersion || data.storage_version); - } - const examIndex = Array.isArray(data.exam_index) - ? data.exam_index - : (Array.isArray(data.examIndex) ? data.examIndex : null); - if (examIndex) { - await this.storage.set('exam_index', examIndex); - } - } - - console.log('[ScoreStorage] Backup restored:', backupId); - return backup; - } catch (error) { - console.error('[ScoreStorage] Failed to restore backup:', error); - throw error; - } - } - - /** - * 获取备份列表 - 统一走 BackupAPI - */ - async getBackups() { - try { - await this.ensureReady(); - if (window.BackupAPI && typeof window.BackupAPI.list === 'function') { - const backups = await window.BackupAPI.list(); - return (Array.isArray(backups) ? backups : []) - .slice() - .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } - const backups = await this.storage.get('manual_backups', []); - return (Array.isArray(backups) ? backups : []) - .slice() - .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } catch (error) { - console.error('[ScoreStorage] Failed to get backups:', error); - return []; - } - } - - /** - * 导出数据 - */ - async exportData(format = 'json') { - await this.ensureReady(); - const exportData = { - exportDate: new Date().toISOString(), - version: this.currentVersion, - practiceRecords: await this.listPracticeRecordsCanonical(), - userStats: await this.getUserStats(), - backups: await this.storage.get(this.storageKeys.backupData, []) - }; - - switch (format.toLowerCase()) { - case 'json': - return JSON.stringify(exportData, null, 2); - case 'csv': - return this.convertToCSV(exportData.practiceRecords); - default: - throw new Error(`Unsupported export format: ${format}`); - } - } - - /** - * 转换为CSV格式 - */ - convertToCSV(records) { - if (records.length === 0) return ''; - - const headers = [ - 'ID', '考试ID', '开始时间', '结束时间', '用时(秒)', - '状态', '分数', '总题数', '正确数', '准确率', - '分类', '频率', '题目标题' - ]; - - const rows = records.map(record => [ - record.id, - record.examId, - record.startTime, - record.endTime, - record.duration, - record.status, - record.score, - record.totalQuestions, - record.correctAnswers, - Math.round(record.accuracy * 100) + '%', - record.metadata.category || '', - record.metadata.frequency || '', - record.metadata.examTitle || '' - ]); - - return [headers, ...rows] - .map(row => row.map(cell => `"${cell}"`).join(',')) - .join('\n'); - } - - /** - * 导入数据 - */ - async importData(importData, options = {}) { - try { - await this.ensureReady(); - const payload = typeof importData === 'string' ? JSON.parse(importData) : importData; - - const records = this.extractPracticeRecordsFromPayload(payload); - const stats = this.extractUserStatsFromPayload(payload); - - if (!Array.isArray(records) || records.length === 0) { - throw new Error('Invalid import data format: no practice records found'); - } - - // 标准化记录,避免字段缺失 - const standardizedRecords = records.map((r) => { - try { - return this.standardizeRecord(r); - } catch (e) { - console.warn('[ScoreStorage] 标准化导入记录失败,跳过:', r && r.id, e); - return null; - } - }).filter(Boolean); - - // 创建备份 - await this.createBackup('pre_import_backup'); - - if (options.merge) { - // 合并模式:按 id 去重,保留导入集中的最新(后出现的覆盖) - const existingRecords = await this.listPracticeRecordsCanonical(); - const mergedMap = new Map(); - existingRecords.forEach((rec) => { - if (rec && rec.id) mergedMap.set(rec.id, rec); - }); - standardizedRecords.forEach((rec) => { - if (rec && rec.id) mergedMap.set(rec.id, rec); - }); - const mergedRecords = Array.from(mergedMap.values()); - await this.replacePracticeRecordsCanonical(mergedRecords, { updateStats: true }); - console.log(`Imported ${standardizedRecords.length} records (merge mode), total ${mergedRecords.length}`); - - } else { - // 替换模式:完全替换数据 - await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: !stats }); - - if (stats) { - const api = this.getPracticeRecordAPI(['writeStats']); - await api.writeStats(stats); - } - - console.log(`Imported ${standardizedRecords.length} records (replace mode)`); - } - - return true; - - } catch (error) { - console.error('Failed to import data:', error); - throw error; - } - } - - extractPracticeRecordsFromPayload(payload) { - if (!payload) return []; - if (Array.isArray(payload)) return payload; - if (Array.isArray(payload.practiceRecords)) return payload.practiceRecords; - if (Array.isArray(payload.practice_records)) return payload.practice_records; - if (Array.isArray(payload.data?.practice_records)) return payload.data.practice_records; - if (Array.isArray(payload.data?.practiceRecords)) return payload.data.practiceRecords; - if (payload.data?.exam_system_practice_records && Array.isArray(payload.data.exam_system_practice_records.data)) { - return payload.data.exam_system_practice_records.data; - } - if (payload.exam_system_practice_records && Array.isArray(payload.exam_system_practice_records.data)) { - return payload.exam_system_practice_records.data; - } - return []; - } - - extractUserStatsFromPayload(payload) { - if (!payload || typeof payload !== 'object') return null; - return payload.userStats - || payload.user_stats - || payload.data?.userStats - || payload.data?.user_stats - || null; - } - - // Note: 备份相关方法已移除,现在使用DataBackupManager - - /** - * 生成记录ID - */ - generateRecordId() { - return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - /** - * 获取存储统计信息 - */ - async getStorageStats() { - await this.ensureReady(); - const records = await this.listPracticeRecordsCanonical(); - const backups = await this.storage.get(this.storageKeys.backupData, []); - - return { - totalRecords: records.length, - totalBackups: backups.length, - oldestRecord: records.length > 0 ? records[0].startTime : null, - newestRecord: records.length > 0 ? records[records.length - 1].startTime : null, - storageVersion: await this.storage.get(this.storageKeys.storageVersion), - estimatedSize: await this.estimateStorageSize() - }; - } - - /** - * 估算存储大小 - */ - async estimateStorageSize() { - await this.ensureReady(); - const data = { - practiceRecords: await this.listPracticeRecordsCanonical(), - userStats: await this.getUserStats(), - backupData: await this.storage.get(this.storageKeys.backupData, []) - }; - - const jsonString = JSON.stringify(data); - return jsonString.length; // 字节数的近似值 - } - - // Note: destroy方法已移除,因为备份功能现在由DataBackupManager处理 -} - -// 确保全局可用 -window.ScoreStorage = ScoreStorage; diff --git a/js/core/siteDataReset.js b/js/core/siteDataReset.js new file mode 100644 index 00000000..48734f9d --- /dev/null +++ b/js/core/siteDataReset.js @@ -0,0 +1,768 @@ +/** + * Destructive browser-site reset. + * + * This path deliberately bypasses AppData domain mutations. A reset must not + * append operation journals, rebuild projectors, or flush an empty snapshot to + * the bound external backup folder. + */ +(function initSiteDataReset(global) { + 'use strict'; + + if (global.SiteDataReset && global.SiteDataReset.__v2 === true) { + if (typeof global.clearCache !== 'function') { + global.clearCache = global.SiteDataReset.request; + } + return; + } + + var DATABASE_NAMES = Object.freeze([ + 'IELTSAtlasDataV2', + 'ExamSystemDB', + 'IELTSAtlasExternalBackupV2' + ]); + /** + * How long a `blocked` deletion is allowed to keep waiting before it is + * reported as a failure. + * + * A cooperative peer (data kernel connections install `onversionchange` and + * close immediately) releases the database within a tick, while a peer that + * is in the middle of a long write can legitimately hold it for a few + * seconds. Waiting far beyond that only makes an unrecoverable block look + * like a frozen UI, and every database is deleted in parallel, so this is + * the worst case for the whole reset rather than a per-database cost. + */ + var BLOCKED_DELETE_TIMEOUT_MS = 8000; + var EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS = 8000; + /** + * `IDBFactory.deleteDatabase()` has no `abort()`. Once the blocked timeout + * wins, the browser keeps the request armed and will drop the database the + * moment the peer connection closes — possibly minutes later, possibly after + * this page reloaded and started using a freshly created database. + * + * `pendingDeletions` is that un-cancellable tail: a database name stays here + * from the moment we give up waiting until the browser actually reports the + * request as done. While a name is listed the reset is "armed but not + * finished", which is a materially different state from both "succeeded" and + * "failed" and must be surfaced as such. + */ + var pendingDeletions = new Map(); + var pendingDeletionSequence = 0; + /** + * Cross-refresh recovery marker. + * + * A reloaded page cannot observe the previous page's `IDBRequest` — that + * object died with the old realm — so the in-memory registry above is lost on + * every reload. The marker carries the *fact* that a reset is still armed + * across the reload so the new page can tell the user the truth instead of + * looking pristine. + * + * It never re-arms a delete by itself. A later realm must obtain explicit + * recovery confirmation before it may queue a replacement deletion. + */ + var PENDING_DELETION_MARKER_KEY = 'ielts_atlas:v2:site-reset:pending-deletions'; + var WINDOW_NAME_MARKER_PREFIX = '__IELTS_ATLAS_SITE_RESET__:'; + /** + * The marker is written *after* `clearWebStorage()` (it would be wiped + * otherwise), which means it is the one key that survives a "clear + * everything" run. Age is used to strengthen the recovery warning, not to + * guess that the underlying request completed. Explicit recovery confirmation + * is the bounded escape hatch for a marker whose old realm is gone forever. + */ + var PENDING_DELETION_MARKER_TTL_MS = 600000; + var adoptedPendingDatabases = []; + var adoptedPendingMarkerState = null; + var resetPromise = null; + + function nowMs() { + try { + if (typeof Date === 'function' && typeof Date.now === 'function') return Date.now(); + } catch (_) { /* exotic host */ } + return 0; + } + + function notify(message, type) { + if (typeof global.showMessage === 'function') { + global.showMessage(message, type || 'info'); + } else if (global.console && typeof global.console.log === 'function') { + global.console.log('[SiteDataReset] ' + message); + } + } + + // Timers are looked up defensively: this module is also loaded inside test + // realms and worker-like hosts that do not expose the full window surface. + function hostSetTimeout(callback, delay) { + try { + if (global && typeof global.setTimeout === 'function') { + return { id: global.setTimeout(callback, delay), host: global }; + } + } catch (_) { /* fall through to the ambient timer */ } + if (typeof setTimeout === 'function') { + return { id: setTimeout(callback, delay), host: null }; + } + return null; + } + + function hostClearTimeout(handle) { + if (!handle) return null; + try { + if (handle.host && typeof handle.host.clearTimeout === 'function') { + handle.host.clearTimeout(handle.id); + return null; + } + } catch (_) { + return null; + } + if (typeof clearTimeout === 'function') clearTimeout(handle.id); + return null; + } + + function createBlockedError(name) { + var error = new Error( + '数据库被其他 IELTS Atlas 标签页占用,未能删除:' + name + + '(等待 ' + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒后放弃)。' + ); + error.code = 'DELETE_DATABASE_BLOCKED'; + error.blocked = true; + error.database = name; + error.timeoutMs = BLOCKED_DELETE_TIMEOUT_MS; + return error; + } + + function createQuiesceTimeoutError() { + var error = new Error( + '外部备份停止写入超时(等待 ' + + Math.round(EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS / 1000) + ' 秒)。' + ); + error.code = 'EXTERNAL_BACKUP_QUIESCE_TIMEOUT'; + error.timeoutMs = EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS; + return error; + } + + function readStorage(name) { + try { + var storage = global[name]; + if (storage && typeof storage.getItem === 'function') return storage; + } catch (_) { /* storage disabled by policy or a sandboxed frame */ } + return null; + } + + function markerStorages() { + return [readStorage('localStorage'), readStorage('sessionStorage')].filter(function (storage, index, all) { + return !!storage && all.indexOf(storage) === index; + }); + } + + function readWindowNameMarker() { + var value = ''; + try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return null; } + var parts = value.split('\n'); + for (var index = parts.length - 1; index >= 0; index -= 1) { + if (parts[index].indexOf(WINDOW_NAME_MARKER_PREFIX) === 0) { + return parts[index].slice(WINDOW_NAME_MARKER_PREFIX.length); + } + } + return null; + } + + function replaceWindowNameMarker(raw) { + var value = ''; + try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return false; } + var retained = value.split('\n').filter(function (part) { + return part.indexOf(WINDOW_NAME_MARKER_PREFIX) !== 0; + }); + if (retained.length === 1 && retained[0] === '') retained = []; + if (raw) retained.push(WINDOW_NAME_MARKER_PREFIX + raw); + try { + global.name = retained.join('\n'); + return raw ? readWindowNameMarker() === raw : readWindowNameMarker() === null; + } catch (_) { + return false; + } + } + + /** + * Persist the recovery marker. Called only from the tail of `perform`, after + * `clearWebStorage()`, so the value is not immediately erased by the very + * reset that produced it. + */ + function writePendingDeletionMarker(names) { + if (!names || !names.length) { + clearPendingDeletionMarker(); + return true; + } + var value = JSON.stringify({ state: 'pending', databases: names.slice(), at: nowMs() }); + var persisted = false; + markerStorages().forEach(function (storage) { + if (typeof storage.setItem !== 'function') return; + try { + storage.setItem(PENDING_DELETION_MARKER_KEY, value); + persisted = storage.getItem(PENDING_DELETION_MARKER_KEY) === value || persisted; + } catch (_) { /* try the other storage */ } + }); + persisted = replaceWindowNameMarker(value) || persisted; + return persisted; + } + + function clearPendingDeletionMarker() { + markerStorages().forEach(function (storage) { + if (typeof storage.removeItem !== 'function') return; + try { + storage.removeItem(PENDING_DELETION_MARKER_KEY); + } catch (_) { /* best-effort */ } + }); + replaceWindowNameMarker(null); + } + + /** + * Read a marker left by a previous page load. + * + * Expired or malformed evidence cannot prove that the old request completed. + * Keep the page in a recoverable confirmation-required state instead of + * silently turning uncertainty into "safe". + */ + function readPendingDeletionMarker() { + var sawMarker = false; + var invalidMarker = false; + var validCandidate = null; + var rawMarkers = []; + markerStorages().forEach(function (storage) { + try { rawMarkers.push(storage.getItem(PENDING_DELETION_MARKER_KEY)); } catch (_) { /* unreadable */ } + }); + rawMarkers.push(readWindowNameMarker()); + rawMarkers.forEach(function (raw) { + if (!raw) return; + sawMarker = true; + var parsed = null; + try { parsed = JSON.parse(raw); } catch (_) { invalidMarker = true; return; } + var names = parsed && parsed.databases; + var state = parsed && parsed.state; + if (state && state !== 'pending' && state !== 'unknown') { + invalidMarker = true; + return; + } + if (!names || typeof names.length !== 'number' || !names.length) { + invalidMarker = true; + return; + } + var adopted = []; + for (var index = 0; index < names.length; index += 1) { + if (DATABASE_NAMES.indexOf(names[index]) !== -1 && adopted.indexOf(names[index]) === -1) { + adopted.push(names[index]); + } + } + if (!adopted.length) { invalidMarker = true; return; } + var at = Number(parsed.at); + var age = nowMs() - (isFinite(at) ? at : 0); + validCandidate = { + databases: adopted, + state: 'unknown', + expired: !isFinite(at) || age < 0 || age > PENDING_DELETION_MARKER_TTL_MS + }; + }); + if (validCandidate) return validCandidate; + if (sawMarker || invalidMarker) { + return { databases: DATABASE_NAMES.slice(), state: 'unknown', corrupt: true }; + } + return { databases: [], state: 'retired' }; + } + + /** + * Register a deletion request we stopped waiting for, and keep watching it. + * + * The handlers installed here are intentionally *not* the ones `settle()` + * detached: those could still resolve the caller's promise and rewrite an + * outcome that has already been reported. These are pure observers — their + * only job is to notice that the un-cancellable request finally ran, so the + * pending state can be retired truthfully instead of by timeout. + */ + function trackPendingDeletion(name, request) { + pendingDeletionSequence += 1; + var token = pendingDeletionSequence; + pendingDeletions.set(name, { token: token, at: nowMs(), request: request }); + + function retire() { + var entry = pendingDeletions.get(name); + // A newer reset attempt may have replaced this entry; only the owner + // of the current token may retire it. + if (!entry || entry.token !== token) return; + pendingDeletions.delete(name); + var remaining = listLivePendingDeletions(); + if (remaining.length) { + writePendingDeletionMarker(remaining); + } else { + clearPendingDeletionMarker(); + } + } + + try { + request.onsuccess = function () { retire(); }; + request.onerror = function () { retire(); }; + // A repeated `onblocked` means the peer is still holding on. Nothing + // to retire yet, but swallow it so it cannot reach a stale handler. + request.onblocked = function () { }; + } catch (_) { + // Read-only handlers are rare, but guessing completion would be + // unsafe. The entry therefore remains restricted until this realm is + // torn down and the cross-refresh recovery flow takes over. + } + return token; + } + + /** + * Live pending deletions: requests this realm issued and can still observe. + * + * Only these gate a new reset. An entry leaves this list the moment the + * browser reports the deletion done, so the common "close the other tab and + * retry" path unblocks immediately rather than waiting out a timer. + */ + function listLivePendingDeletions() { + var names = []; + pendingDeletions.forEach(function (_entry, name) { + if (names.indexOf(name) === -1) names.push(name); + }); + return names; + } + + /** Live plus adopted names — everything worth telling the user about. */ + function listPendingDeletions() { + var names = listLivePendingDeletions(); + for (var index = 0; index < adoptedPendingDatabases.length; index += 1) { + if (names.indexOf(adoptedPendingDatabases[index]) === -1) names.push(adoptedPendingDatabases[index]); + } + return names; + } + + function currentDeletionState() { + if (listLivePendingDeletions().length) return 'pending'; + if (adoptedPendingDatabases.length) return 'unknown'; + return 'retired'; + } + + /** + * The reason a caller must not start a new reset right now, or null. + * + * Live requests only retire on their real terminal event. Cross-refresh + * evidence can be recovered from, but only after explicit confirmation; this + * avoids both an automatic false-safe state and a permanent marker lockout. + */ + function pendingDeletionBlock(options) { + var live = listLivePendingDeletions(); + var adopted = adoptedPendingDatabases.slice(); + if (!live.length && !adopted.length) return null; + var recoveryRequired = !live.length && adopted.length > 0 + && !(options && options.recoveryConfirmed === true); + if (!live.length && !recoveryRequired) return null; + return { + success: false, + reason: recoveryRequired ? 'recovery_confirmation_required' : 'deletion_pending', + deletionPending: true, + pendingDatabases: listPendingDeletions(), + retryable: true, + recoveryConfirmationRequired: recoveryRequired, + deletionState: currentDeletionState(), + markerExpired: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.expired), + markerCorrupt: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.corrupt), + terminal: false, + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; + } + + function pendingDeletionMessage(names) { + return '上一次清理仍在等待其他 IELTS Atlas 标签页关闭:' + names.join('、') + + '。浏览器无法取消这个删除请求,它会在其他标签页关闭后自动执行;' + + '在那之前请不要录入新数据,否则可能被这次迟到的删除一并清掉。'; + } + + function settleWithTimeout(value, timeoutMs, createTimeoutError) { + return new Promise(function (resolve, reject) { + var settled = false; + var timer = hostSetTimeout(function () { + if (settled) return; + settled = true; + reject(createTimeoutError()); + }, timeoutMs); + if (!timer) { + reject(createTimeoutError()); + return; + } + Promise.resolve(value).then(function (result) { + if (settled) return; + settled = true; + hostClearTimeout(timer); + resolve(result); + }, function (error) { + if (settled) return; + settled = true; + hostClearTimeout(timer); + reject(error); + }); + }); + } + + function deleteDatabaseStrict(name) { + return new Promise(function (resolve, reject) { + var indexedDb; + try { + indexedDb = global.indexedDB || null; + } catch (_) { + indexedDb = null; + } + if (!indexedDb || typeof indexedDb.deleteDatabase !== 'function') { + resolve({ name: name, skipped: true }); + return; + } + + var request; + try { + request = indexedDb.deleteDatabase(name); + } catch (error) { + reject(error); + return; + } + + var settled = false; + var blockedTimer = null; + + function settle(complete, payload) { + if (settled) return; + settled = true; + blockedTimer = hostClearTimeout(blockedTimer); + // An IndexedDB deleteDatabase request cannot be aborted. When the + // blocked timeout wins, the browser keeps the request pending and + // will still drop the database once the other tab releases its + // connection. Detaching the handlers here stops a late event from + // rewriting an outcome the caller already acted on; the request is + // then handed to `trackPendingDeletion`, whose observer handlers do + // nothing but retire the pending state when the delete really runs. + try { + request.onsuccess = null; + request.onerror = null; + request.onblocked = null; + } catch (_) { /* exotic hosts may expose read-only handlers */ } + var abandoned = !!(payload && payload.code === 'DELETE_DATABASE_BLOCKED'); + if (abandoned) trackPendingDeletion(name, request); + complete(payload); + } + + request.onsuccess = function () { + settle(resolve, { name: name, deleted: true }); + }; + request.onerror = function () { + settle(reject, request.error || new Error('删除数据库失败:' + name)); + }; + request.onblocked = function () { + if (settled || blockedTimer) return; + notify( + '清理被其他 IELTS Atlas 标签页阻塞,请立即关闭其他标签页;' + + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒内未释放将中止本次清理。', + 'warning' + ); + blockedTimer = hostSetTimeout(function () { + settle(reject, createBlockedError(name)); + }, BLOCKED_DELETE_TIMEOUT_MS); + if (!blockedTimer) { + // No timer API at all: fail fast rather than wait forever. + settle(reject, createBlockedError(name)); + } + }; + }); + } + + function clearWebStorage() { + var failures = []; + ['localStorage', 'sessionStorage'].forEach(function (name) { + var storage; + try { + storage = global[name]; + } catch (error) { + failures.push({ storage: name, error: error }); + return; + } + if (!storage || typeof storage.clear !== 'function') return; + try { + storage.clear(); + } catch (error) { + failures.push({ storage: name, error: error }); + } + }); + return failures; + } + + function reloadTerminal(options) { + if (options && options.reload === false) return false; + if (global.location && typeof global.location.reload === 'function') { + global.location.reload(); + return true; + } + return false; + } + + async function perform(options) { + var opts = options || {}; + if (resetPromise) return resetPromise; + // Refuse to queue a second un-cancellable deletion behind one that is + // still armed. Checked before the singleton is installed so the refusal + // is never cached as "the" result of a reset. + var blockedByPending = pendingDeletionBlock(opts); + if (blockedByPending) { + notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); + return blockedByPending; + } + resetPromise = (async function () { + var externalBackup = global.ExternalBackupService; + var errors = []; + try { + if (externalBackup && typeof externalBackup.prepareForFullReset === 'function') { + await settleWithTimeout( + externalBackup.prepareForFullReset(), + EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, + createQuiesceTimeoutError + ); + } else if (externalBackup && typeof externalBackup.unbindDirectory === 'function') { + await settleWithTimeout( + externalBackup.unbindDirectory(), + EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, + createQuiesceTimeoutError + ); + } + } catch (error) { + errors.push({ stage: 'external-backup-quiesce', error: error }); + } + + var deletionResults = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabaseStrict)); + var blockedDatabases = []; + deletionResults.forEach(function (result, index) { + if (result.status !== 'rejected') return; + var reason = result.reason; + var isBlocked = !!(reason && reason.code === 'DELETE_DATABASE_BLOCKED'); + if (isBlocked) blockedDatabases.push(DATABASE_NAMES[index]); + errors.push({ + stage: isBlocked ? 'delete-database-blocked' : 'delete-database', + database: DATABASE_NAMES[index], + blocked: isBlocked, + error: reason + }); + }); + clearWebStorage().forEach(function (failure) { + errors.push({ + stage: 'clear-web-storage', + storage: failure.storage, + error: failure.error + }); + }); + + // Written after clearWebStorage() on purpose: the reset wipes every + // key, so a marker persisted any earlier would erase itself. This is + // the one key that legitimately survives a full reset, which is why + // it carries its own TTL. + // + // Adopted names are dropped unconditionally here. This run issued a + // fresh deleteDatabase() for every name, and the connection queue is + // FIFO per database: whatever a previous realm queued was necessarily + // processed ahead of the request we just awaited, so it is no longer + // outstanding regardless of how this run ended. + adoptedPendingDatabases = []; + adoptedPendingMarkerState = null; + var stillPending = listLivePendingDeletions(); + var markerPersisted = true; + if (stillPending.length) { + markerPersisted = writePendingDeletionMarker(stillPending); + if (!markerPersisted) { + errors.push({ + stage: 'pending-deletion-marker', + error: new Error('无法持久化仍在等待的数据库删除状态。') + }); + } + } else { + clearPendingDeletionMarker(); + } + + if (errors.length) { + if (blockedDatabases.length) { + notify( + '清理未完成:' + blockedDatabases.join('、') + + ' 仍被其他 IELTS Atlas 标签页占用。浏览器无法取消该删除请求,' + + '它会在其他标签页关闭后自动执行。请关闭全部其他标签页(含练习/听力弹窗)后,' + + '等待当前页面确认删除完成后再重试,在此之前不要继续录入新数据。', + 'error' + ); + } else { + notify('本地数据仅部分清除,页面将刷新;请刷新后再次执行清理。', 'error'); + } + // Keep this realm alive while it owns observable delete requests. + // Reloading would discard the only truthful success/error observer. + var reloadedAfterFailure = stillPending.length ? false : reloadTerminal(opts); + return { + success: false, + reason: 'partial_reset', + blocked: blockedDatabases.length > 0, + blockedDatabases: blockedDatabases, + deletionPending: stillPending.length > 0, + pendingDatabases: stillPending, + markerPersisted: markerPersisted, + deletionState: stillPending.length ? 'pending' : 'retired', + retryable: true, + // `terminal` means "this page was actually torn down". Callers + // use it to decide whether they still own a live document, so + // reporting a reload that never happened strands them on a + // page they believe is gone. + terminal: reloadedAfterFailure, + errors: errors, + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; + } + var reloaded = reloadTerminal(opts); + return { + success: true, + terminal: reloaded, + deletionState: 'retired', + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; + })(); + try { + var outcome = await resetPromise; + // The singleton exists only to collapse duplicate clicks on one + // in-flight run; it is not a result cache. Anything already settled + // must be released, or the next click replays a stale outcome without + // clearing a single byte. + // + // The one case worth keeping is a reset that really did call + // location.reload(): the document is being torn down, and holding the + // resolved promise suppresses clicks landing in that teardown window + // rather than firing a second delete against a dying realm. Reload is + // asynchronous, so those clicks are genuinely reachable. + if (!outcome || outcome.terminal !== true) resetPromise = null; + return outcome; + } catch (error) { + resetPromise = null; + throw error; + } + } + + async function request(options) { + var opts = options || {}; + // Checked before the confirm dialog: asking the user to authorise a + // destructive action we are about to refuse is worse than useless, and a + // second `deleteDatabase()` for a name that is already queued only grows + // the un-cancellable backlog. + var blockedByPending = pendingDeletionBlock(opts); + if (blockedByPending) { + if (blockedByPending.recoveryConfirmationRequired) { + var recoveryConfirmed = false; + try { + recoveryConfirmed = global.confirm( + '浏览器记录显示上一次数据库删除可能仍在等待。继续恢复会重新排队删除,' + + '请先关闭其他 IELTS Atlas 标签页;确定继续吗?' + ); + } catch (_) { recoveryConfirmed = false; } + if (recoveryConfirmed) { + opts = Object.assign({}, opts, { recoveryConfirmed: true }); + } else { + notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); + return blockedByPending; + } + } else { + notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); + return blockedByPending; + } + } + var confirmed = opts.confirmed === true; + if (!confirmed) { + try { + confirmed = global.confirm( + '确定要清除全部浏览器本地数据并返回首次启动状态吗?\n\n' + + '练习记录、题库、词汇、设置、应用内备份和本地文件夹绑定都会清除;' + + '外部文件夹中的 JSON 备份不会删除。' + ); + } catch (_) { + confirmed = false; + } + } + if (!confirmed) return { + success: false, + reason: 'cancelled', + deletionState: currentDeletionState() + }; + + notify('正在清除全部本地数据…', 'info'); + try { + return await perform(opts); + } catch (error) { + if (global.console && typeof global.console.error === 'function') { + global.console.error('[SiteDataReset] full reset failed:', error); + } + notify('清除失败:' + (error && error.message ? error.message : '浏览器存储不可用'), 'error'); + return { + success: false, + reason: 'reset_failed', + deletionState: currentDeletionState(), + error: error + }; + } + } + + /** + * Adopt a marker written before the last reload and warn once. + * + * It never issues a delete. It does require explicit confirmation before a + * recovery reset, so stale evidence remains recoverable without being treated + * as proof that the late-deletion hazard disappeared. + * + * The warning is deferred because this module ships in core-foundation, + * which index.html loads *before* the ui-shell/legacy bundles that define + * `showMessage`. Warning synchronously would route the one notice the user + * actually needs into console.log instead of the message center. + */ + function adoptPendingDeletionsFromPreviousPage() { + adoptedPendingMarkerState = readPendingDeletionMarker(); + adoptedPendingDatabases = adoptedPendingMarkerState.databases; + if (!adoptedPendingDatabases.length) return; + var announced = false; + function announce() { + if (announced) return; + announced = true; + // Re-read: a reset may have completed and retired the marker while we + // were waiting for the UI layer to come up. + if (!adoptedPendingDatabases.length) return; + notify(pendingDeletionMessage(adoptedPendingDatabases), 'warning'); + } + if (typeof global.showMessage === 'function') { + announce(); + return; + } + var attempts = 0; + function poll() { + attempts += 1; + if (typeof global.showMessage === 'function' || attempts >= 20) { + announce(); + return; + } + hostSetTimeout(poll, 250); + } + if (!hostSetTimeout(poll, 250)) announce(); + } + + global.SiteDataReset = Object.freeze({ + __v2: true, + DATABASE_NAMES: DATABASE_NAMES, + PENDING_DELETION_MARKER_KEY: PENDING_DELETION_MARKER_KEY, + deleteDatabaseStrict: deleteDatabaseStrict, + perform: perform, + request: request, + /** Names whose un-cancellable deletion has not reported back yet. */ + pendingDeletions: listPendingDeletions, + /** True while a previous deletion is still armed; see `pendingDeletions`. */ + isDeletionPending: function () { + return listPendingDeletions().length > 0; + }, + recoveryConfirmationRequired: function () { + return adoptedPendingDatabases.length > 0; + }, + deletionState: currentDeletionState + }); + global.clearCache = request; + adoptPendingDeletionsFromPreviousPage(); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/storageProviderRegistry.js b/js/core/storageProviderRegistry.js deleted file mode 100644 index dc220e5b..00000000 --- a/js/core/storageProviderRegistry.js +++ /dev/null @@ -1,83 +0,0 @@ -(function(window) { - const listeners = new Set(); - let providers = null; - - function normalizeProviders(input) { - if (!input || typeof input !== 'object') { - return null; - } - const normalized = { - storageManager: input.storageManager || window.storage || null, - persistentStore: input.persistentStore || window.persistentStore || null, - preferenceStore: input.preferenceStore || window.preferenceStore || null, - repositories: input.repositories || null, - simpleStorageWrapper: input.simpleStorageWrapper || null - }; - if (!normalized.repositories) { - return null; - } - return normalized; - } - - function notifyListeners(payload) { - listeners.forEach((listener) => { - try { - listener(payload); - } catch (error) { - console.error('[StorageProviderRegistry] listener failed:', error); - } - }); - } - - function registerStorageProviders(input) { - const normalized = normalizeProviders(input); - if (!normalized) { - throw new Error('registerStorageProviders requires repositories'); - } - providers = normalized; - - if (!window.dataRepositories) { - window.dataRepositories = normalized.repositories; - } - if (!window.storage && normalized.storageManager) { - window.storage = normalized.storageManager; - } - if (!window.persistentStore && normalized.persistentStore) { - window.persistentStore = normalized.persistentStore; - } - if (!window.preferenceStore && normalized.preferenceStore) { - window.preferenceStore = normalized.preferenceStore; - } - if (normalized.simpleStorageWrapper && !window.simpleStorageWrapper) { - window.simpleStorageWrapper = normalized.simpleStorageWrapper; - } - - notifyListeners(Object.assign({}, providers)); - return providers; - } - - function onProvidersReady(callback) { - if (typeof callback !== 'function') { - return () => {}; - } - listeners.add(callback); - if (providers) { - try { - callback(Object.assign({}, providers)); - } catch (error) { - console.error('[StorageProviderRegistry] immediate callback failed:', error); - } - } - return () => listeners.delete(callback); - } - - function getCurrentProviders() { - return providers ? Object.assign({}, providers) : null; - } - - window.StorageProviderRegistry = { - registerStorageProviders, - onProvidersReady, - getCurrentProviders - }; -})(window); diff --git a/js/core/vocabStore.js b/js/core/vocabStore.js index bda51c18..17f4645b 100644 --- a/js/core/vocabStore.js +++ b/js/core/vocabStore.js @@ -5,53 +5,40 @@ id: 'default', name: 'IELTS 核心词表', icon: '📚', - source: 'builtin', - storageKey: 'vocab_words' + source: 'builtin' }, 'spelling-errors-p1': { id: 'spelling-errors-p1', name: 'P1 拼写错误', icon: '📝', - source: 'p1', - storageKey: 'vocab_list_p1_errors' + source: 'p1' }, 'spelling-errors-p4': { id: 'spelling-errors-p4', name: 'P4 拼写错误', icon: '📝', - source: 'p4', - storageKey: 'vocab_list_p4_errors' + source: 'p4' }, 'spelling-errors-master': { id: 'spelling-errors-master', name: '综合错误词表', icon: '📚', - source: 'all', - storageKey: 'vocab_list_master_errors' + source: 'all' }, 'custom': { id: 'custom', name: '自定义词表', icon: '✏️', - source: 'user', - storageKey: 'vocab_list_custom' + source: 'user' }, 'reading-highlights': { id: 'reading-highlights', name: '阅读高亮生词', icon: '📖', - source: 'reading-highlight', - storageKey: 'vocab_list_reading_highlights' + source: 'reading-highlight' } }); - const STORAGE_KEYS = Object.freeze({ - WORDS: 'vocab_words', - CONFIG: 'vocab_user_config', - REVIEW_QUEUE: 'vocab_review_queue', - ACTIVE_LIST: 'vocab_active_list_id' - }); - const DEFAULT_CONFIG = Object.freeze({ dailyNew: 20, reviewLimit: 100, @@ -60,29 +47,31 @@ notify: true }); - const DEFAULT_REVIEW_QUEUE = Object.freeze([]); const DEFAULT_LIST_ID = 'default'; const DEFAULT_LEXICON_URL = 'assets/wordlists/ielts_core.json'; const SPELLING_ERROR_LIST_IDS = new Set(['spelling-errors-p1', 'spelling-errors-p4', 'spelling-errors-master']); const state = { - repositories: null, - metaRepo: null, - storageManager: null, words: [], wordIndex: new Map(), config: { ...DEFAULT_CONFIG }, - reviewQueue: DEFAULT_REVIEW_QUEUE.slice(), ready: false, readyPromise: null, readyResolvers: [], loadingPromise: null, - registryUnsubscribe: null, lastLoadSource: 'init', activeListId: DEFAULT_LIST_ID, listCache: new Map() }; + function cloneValue(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + function emitReady(value) { if (state.ready) { return; @@ -281,59 +270,30 @@ }); } - async function persist(key, value) { - try { - if (state.metaRepo && typeof state.metaRepo.set === 'function') { - await state.metaRepo.set(key, value, { clone: true }); - return true; - } - if (state.storageManager && typeof state.storageManager.set === 'function') { - await state.storageManager.set(key, value); - return true; - } - if (typeof localStorage !== 'undefined') { - localStorage.setItem(key, JSON.stringify(value)); - return true; - } - } catch (error) { - console.error('[VocabStore] persist error:', error); - } - return false; + async function requireVocabData() { + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; + return window.AppData.vocab; } - async function read(key, defaultValue) { - if (state.metaRepo && typeof state.metaRepo.get === 'function') { - try { - const value = await state.metaRepo.get(key, defaultValue); - if (value !== undefined) { - return value; - } - } catch (error) { - console.warn('[VocabStore] metaRepo读取失败:', error); - } - } - if (state.storageManager && typeof state.storageManager.get === 'function') { - try { - const value = await state.storageManager.get(key, defaultValue); - if (value !== undefined) { - return value; - } - } catch (error) { - console.warn('[VocabStore] storageManager读取失败:', error); - } - } - if (typeof localStorage !== 'undefined') { - try { - const raw = localStorage.getItem(key); - if (!raw) { - return defaultValue; - } - return JSON.parse(raw); - } catch (error) { - console.warn('[VocabStore] localStorage解析失败:', error); - } - } - return defaultValue; + async function readListData(listId) { + const vocab = await requireVocabData(); + if (listId === DEFAULT_LIST_ID) return vocab.listWords(); + const collections = await vocab.listCollections(); + return Object.prototype.hasOwnProperty.call(collections, listId) ? collections[listId] : null; + } + + async function saveListData(listId, value) { + const vocab = await requireVocabData(); + const words = value && typeof value === 'object' && Array.isArray(value.words) ? value.words : value; + await vocab.replaceListWords({ listId, words: Array.isArray(words) ? words : [] }); + return true; + } + + async function saveConfigData(configPatch = state.config) { + const vocab = await requireVocabData(); + await vocab.patchConfig(Object.assign({}, configPatch, { activeListId: state.activeListId })); + return true; } function mergeConfig(config) { @@ -353,15 +313,6 @@ rebuildIndex(); } - function getStorageKeyForListId(listId) { - const targetId = typeof listId === 'string' && VOCAB_LISTS[listId] ? listId : DEFAULT_LIST_ID; - return VOCAB_LISTS[targetId].storageKey; - } - - function getActiveStorageKey() { - return getStorageKeyForListId(state.activeListId); - } - function isSpellingErrorList(listId) { return SPELLING_ERROR_LIST_IDS.has(listId); } @@ -475,29 +426,26 @@ return state.loadingPromise; } state.loadingPromise = (async () => { - const [storedConfig, storedQueue, storedActiveList] = await Promise.all([ - read(STORAGE_KEYS.CONFIG, { ...DEFAULT_CONFIG }), - read(STORAGE_KEYS.REVIEW_QUEUE, DEFAULT_REVIEW_QUEUE.slice()), - read(STORAGE_KEYS.ACTIVE_LIST, DEFAULT_LIST_ID) - ]); + const vocab = await requireVocabData(); + const storedConfig = await vocab.getConfig(); + const storedActiveList = storedConfig && storedConfig.activeListId; state.activeListId = typeof storedActiveList === 'string' && VOCAB_LISTS[storedActiveList] ? storedActiveList : DEFAULT_LIST_ID; - const activeStorageKey = getStorageKeyForListId(state.activeListId); - const storedWords = await read(activeStorageKey, []); + const storedWords = await readListData(state.activeListId); const normalizedWords = normalizeStoredListWords(storedWords, state.activeListId); if (normalizedWords.length) { setWordsInternal(normalizedWords); - state.lastLoadSource = state.metaRepo ? 'meta' : (state.storageManager ? 'storage' : 'localStorage'); + state.lastLoadSource = 'appData-v2'; } state.config = mergeConfig(storedConfig); - state.reviewQueue = Array.isArray(storedQueue) ? storedQueue.map((id) => String(id)) : []; })() .catch((error) => { console.error('[VocabStore] 初始化加载失败:', error); + throw error; }) .finally(() => { state.loadingPromise = null; @@ -507,8 +455,7 @@ async function ensureDefaultLexicon() { try { - const defaultStorageKey = getStorageKeyForListId(DEFAULT_LIST_ID); - const storedDefault = await read(defaultStorageKey, []); + const storedDefault = await readListData(DEFAULT_LIST_ID); const normalizedStored = normalizeStoredListWords(storedDefault, DEFAULT_LIST_ID); const pollutedBySpellingList = isLikelySpellingErrorSnapshot(normalizedStored); if (normalizedStored.length && !pollutedBySpellingList) { @@ -526,7 +473,7 @@ console.warn('[VocabStore] 默认词库为空'); return []; } - await persist(defaultStorageKey, normalized); + await saveListData(DEFAULT_LIST_ID, normalized); if (state.activeListId === DEFAULT_LIST_ID) { setWordsInternal(normalized); state.lastLoadSource = 'default'; @@ -548,8 +495,8 @@ }); return normalized; } catch (error) { - console.warn('[VocabStore] 默认词库加载失败:', error); - return []; + console.error('[VocabStore] 默认词库加载失败:', error); + throw error; } } @@ -559,87 +506,39 @@ emitReady(true); } - function connectToProviders() { - if (state.registryUnsubscribe || state.repositories || state.storageManager) { - return; - } - const registry = window.StorageProviderRegistry; - if (registry && typeof registry.onProvidersReady === 'function') { - state.registryUnsubscribe = registry.onProvidersReady((payload) => { - if (payload && payload.repositories) { - attachRepositories(payload.repositories); - } - if (payload && payload.storageManager) { - state.storageManager = payload.storageManager; - } - }); - const current = typeof registry.getCurrentProviders === 'function' ? registry.getCurrentProviders() : null; - if (current) { - if (current.repositories) { - attachRepositories(current.repositories); - } - if (current.storageManager) { - state.storageManager = current.storageManager; - } - } - return; - } - if (window.dataRepositories) { - attachRepositories(window.dataRepositories); - } - if (window.storage) { - state.storageManager = window.storage; - } - } - - async function attachRepositories(repositories) { - if (!repositories || state.repositories === repositories) { - return; - } - state.repositories = repositories; - state.metaRepo = repositories.meta || null; - await loadState(); - if (!state.words.length) { - await ensureDefaultLexicon(); - } - await persist(getActiveStorageKey(), state.words); - await persist(STORAGE_KEYS.CONFIG, state.config); - await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue); - emitReady(true); - } - function getWords() { - return state.words.map((word) => ({ ...word })); + return cloneValue(state.words); } - async function setWords(words) { + async function mergeWords(words) { const normalized = Array.isArray(words) ? words.map((word) => normalizeWordRecord(word)).filter(Boolean) : []; - setWordsInternal(normalized); - await persist(getActiveStorageKey(), normalized); + const vocab = await requireVocabData(); + const receipt = await vocab.mergeListWords({ listId: state.activeListId, words: normalized }); + const committedWords = Array.isArray(receipt.words) ? receipt.words : []; + setWordsInternal(committedWords.map((word) => normalizeWordRecord(word)).filter(Boolean)); state.listCache.delete(state.activeListId); - return getWords(); + return { + words: getWords(), + addedCount: Number(receipt.addedCount) || 0, + updatedCount: Number(receipt.updatedCount) || 0 + }; } async function updateWord(id, patch = {}) { if (!id || !state.wordIndex.has(id)) { return null; } - const original = state.wordIndex.get(id); - const updated = normalizeWordRecord({ - ...original, - ...patch, - id, - updatedAt: getNow() - }); + const vocab = await requireVocabData(); + const receipt = await vocab.patchWord({ listId: state.activeListId, wordId: id, patch }); + const updated = normalizeWordRecord(receipt.word); const index = state.words.findIndex((word) => word.id === id); if (index >= 0 && updated) { state.words.splice(index, 1, updated); state.wordIndex.set(id, updated); - await persist(getActiveStorageKey(), state.words); state.listCache.delete(state.activeListId); - return { ...updated }; + return cloneValue(updated); } return null; } @@ -649,19 +548,29 @@ } async function setConfig(config) { - state.config = mergeConfig(config); - await persist(STORAGE_KEYS.CONFIG, state.config); + const next = mergeConfig(config); + await saveConfigData(next); + state.config = next; return getConfig(); } - function getReviewQueue() { - return state.reviewQueue.slice(); - } - - async function setReviewQueue(queue) { - state.reviewQueue = Array.isArray(queue) ? queue.map((id) => String(id)) : []; - await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue); - return getReviewQueue(); + async function replaceProgress(words, config = {}, listId = null) { + const normalized = Array.isArray(words) + ? words.map((word) => normalizeWordRecord(word)).filter(Boolean) + : []; + const requestedListId = typeof listId === 'string' && listId.trim() + ? listId.trim() + : (typeof config.activeListId === 'string' && config.activeListId.trim() + ? config.activeListId.trim() + : state.activeListId); + const nextConfig = mergeConfig({ ...config, activeListId: requestedListId }); + const vocab = await requireVocabData(); + await vocab.replaceProgress({ listId: requestedListId, words: normalized, config: nextConfig }); + state.config = nextConfig; + state.activeListId = requestedListId; + setWordsInternal(normalized); + state.listCache.delete(requestedListId); + return { words: getWords(), config: getConfig() }; } function getDueWords(referenceTime = new Date()) { @@ -800,8 +709,7 @@ } try { - const storageKey = listConfig.storageKey; - let storedData = await read(storageKey, null); + let storedData = await readListData(listId); if (listId === DEFAULT_LIST_ID && (!storedData || (Array.isArray(storedData) && storedData.length === 0))) { const ensured = await ensureDefaultLexicon(); storedData = ensured; @@ -836,7 +744,7 @@ return listData; } catch (error) { console.error('[VocabStore] loadList 失败:', error); - return null; + throw error; } } @@ -861,25 +769,11 @@ } try { - // 保存当前词表到存储(如果有修改) - if (state.activeListId && state.words.length > 0) { - const currentConfig = VOCAB_LISTS[state.activeListId]; - if (currentConfig) { - await persist(currentConfig.storageKey, state.words); - } - } - - // 切换到新词表 + const vocab = await requireVocabData(); + await vocab.activateList(listId); state.activeListId = listId; setWordsInternal(listData.words || []); state.listCache.delete(listId); - - // 保存激活的词表 ID - await persist(STORAGE_KEYS.ACTIVE_LIST, listId); - - // 清空复习队列(新词表需要重新生成队列) - state.reviewQueue = []; - await persist(STORAGE_KEYS.REVIEW_QUEUE, []); return true; } catch (error) { @@ -907,8 +801,7 @@ // 从存储读取 try { - const listConfig = VOCAB_LISTS[listId]; - const storedData = await read(listConfig.storageKey, null); + const storedData = await readListData(listId); // 检查是否为拼写错误词表格式 if (storedData && typeof storedData === 'object' && Array.isArray(storedData.words)) { @@ -920,7 +813,7 @@ return 0; } catch (error) { console.error('[VocabStore] getListWordCount 失败:', error); - return 0; + throw error; } } @@ -988,8 +881,7 @@ } await init(); const listId = 'reading-highlights'; - const listConfig = VOCAB_LISTS[listId]; - const storedData = await read(listConfig.storageKey, []); + const storedData = await readListData(listId); const words = normalizeStoredListWords(storedData, listId); const key = normalized.word.toLowerCase(); const existingIndex = words.findIndex((entry) => String(entry.word || '').trim().toLowerCase() === key); @@ -1005,7 +897,7 @@ } else { words.push(normalized); } - await persist(listConfig.storageKey, words.filter(Boolean)); + await saveListData(listId, words.filter(Boolean)); state.listCache.delete(listId); if (state.activeListId === listId) { setWordsInternal(words.filter(Boolean)); @@ -1015,7 +907,6 @@ async function init() { ensureReadyPromise(); - connectToProviders(); if (!state.ready) { await bootstrap(); } @@ -1025,12 +916,11 @@ const api = { init, getWords, - setWords, + mergeWords, updateWord, getConfig, setConfig, - getReviewQueue, - setReviewQueue, + replaceProgress, getDueWords, getNewWords, loadList, diff --git a/js/data/dataSources/storageDataSource.js b/js/data/dataSources/storageDataSource.js deleted file mode 100644 index 4294fd8d..00000000 --- a/js/data/dataSources/storageDataSource.js +++ /dev/null @@ -1,137 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - function isProtectedPracticeDataKey(key) { - return key === 'practice_records' || key === 'user_stats'; - } - - class StorageTransactionContext { - constructor(storageManager, options = {}) { - this.storage = storageManager; - this.createInternalOptions = typeof options.createInternalOptions === 'function' - ? options.createInternalOptions - : null; - this.operations = []; - this.cache = new Map(); - } - - _internalOptions(key) { - if (this.createInternalOptions) { - return this.createInternalOptions(); - } - if (isProtectedPracticeDataKey(key)) { - throw new Error(`StorageTransactionContext cannot access protected key ${key} without internal storage access`); - } - return { skipPracticeCoreRedirect: true }; - } - - async get(key, defaultValue) { - if (this.cache.has(key)) { - return this.cache.get(key); - } - const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue; - const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key)); - const finalValue = value === undefined ? resolvedDefault : value; - this.cache.set(key, finalValue); - return finalValue; - } - - set(key, value) { - this.cache.set(key, value); - this.operations.push({ type: 'set', key, value }); - } - - remove(key) { - this.cache.delete(key); - this.operations.push({ type: 'remove', key }); - } - - async commit() { - for (const op of this.operations) { - if (op.type === 'set') { - await this.storage.set(op.key, op.value, this._internalOptions(op.key)); - } else if (op.type === 'remove') { - await this.storage.remove(op.key, this._internalOptions(op.key)); - } - } - this.operations = []; - } - - async rollback() { - this.operations = []; - } - } - - class StorageDataSource { - constructor(storageManager, options = {}) { - if (!storageManager) { - throw new Error('StorageDataSource requires a StorageManager instance'); - } - this.storage = storageManager; - this.createInternalOptions = typeof options.createInternalOptions === 'function' - ? options.createInternalOptions - : null; - this._queue = Promise.resolve(); - } - - _internalOptions(key) { - if (this.createInternalOptions) { - return this.createInternalOptions(); - } - if (isProtectedPracticeDataKey(key)) { - throw new Error(`StorageDataSource cannot access protected key ${key} without internal storage access`); - } - return { skipPracticeCoreRedirect: true }; - } - - async read(key, defaultValue) { - const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue; - const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key)); - return value === undefined ? resolvedDefault : value; - } - - async write(key, value) { - return this._enqueue(async () => { - await this.storage.set(key, value, this._internalOptions(key)); - return true; - }); - } - - async remove(key) { - return this._enqueue(async () => { - await this.storage.remove(key, this._internalOptions(key)); - return true; - }); - } - - async runTransaction(handler, options = {}) { - if (typeof handler !== 'function') { - throw new Error('StorageDataSource.runTransaction requires a handler function'); - } - const label = options.label || 'storage-transaction'; - return this._enqueue(async () => { - const context = new StorageTransactionContext(this.storage, { - createInternalOptions: this.createInternalOptions - }); - try { - const result = await handler(context); - await context.commit(); - return result; - } catch (error) { - await context.rollback(); - console.error(`[StorageDataSource] Transaction failed (${label}):`, error); - throw error; - } - }); - } - - _enqueue(task) { - const next = this._queue.then(task); - this._queue = next.catch(() => {}); - return next; - } - } - - ExamData.StorageTransactionContext = StorageTransactionContext; - ExamData.StorageDataSource = StorageDataSource; -})(window); diff --git a/js/data/index.js b/js/data/index.js deleted file mode 100644 index 69cfa385..00000000 --- a/js/data/index.js +++ /dev/null @@ -1,241 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - function createDefaultUserStats() { - const now = new Date().toISOString(); - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - lastPracticeDate: null, - achievements: [], - createdAt: now, - updatedAt: now - }; - } - - function createDefaultVocabConfig() { - return { - dailyNew: 20, - reviewLimit: 100, - masteryCount: 4, - theme: 'auto', - notify: true - }; - } - - function createMetaFacade(metaRepo) { - function assertAllowedKey(key) { - if (key === 'user_stats') { - throw new Error('user_stats must go through PracticeRecordAPI'); - } - } - - return Object.freeze({ - async get(key, defaultValue, options = {}) { - assertAllowedKey(key); - return await metaRepo.get(key, defaultValue, options); - }, - async set(key, value, options = {}) { - assertAllowedKey(key); - return await metaRepo.set(key, value, options); - }, - async remove(key, options = {}) { - assertAllowedKey(key); - return await metaRepo.remove(key, options); - }, - async runConsistencyCheck(keys) { - const targetKeys = Array.isArray(keys) - ? keys.filter((key) => key !== 'user_stats') - : undefined; - return await metaRepo.runConsistencyCheck(targetKeys); - } - }); - } - - function bootstrap() { - if (!window.persistentStore) { - console.warn('[data/index] StorageManager 未就绪,延迟初始化数据仓库'); - setTimeout(bootstrap, 100); - return; - } - - if (window.dataRepositories) { - return; - } - - if (!window.PracticeCore || typeof window.PracticeCore.__installInternalRepositories !== 'function') { - console.warn('[data/index] PracticeCore internal installer 未就绪,延迟初始化数据仓库'); - setTimeout(bootstrap, 100); - return; - } - - let createInternalOptions = null; - if (typeof window.__installStorageInternalAccess === 'function') { - window.__installStorageInternalAccess((factory) => { - createInternalOptions = typeof factory === 'function' ? factory : null; - return Boolean(createInternalOptions); - }); - } - if (!createInternalOptions) { - console.warn('[data/index] Storage internal access 未就绪,延迟初始化数据仓库'); - setTimeout(bootstrap, 100); - return; - } - - const dataSource = new ExamData.StorageDataSource(window.persistentStore, { - createInternalOptions - }); - const registry = new ExamData.DataRepositoryRegistry(dataSource); - - const practiceRepo = new ExamData.PracticeRepository(dataSource, { maxRecords: 1000 }); - const settingsRepo = new ExamData.SettingsRepository(dataSource); - const backupRepo = new ExamData.BackupRepository(dataSource, { maxBackups: 20 }); - const metaRepo = new ExamData.MetaRepository(dataSource, { - user_stats: { - defaultValue: createDefaultUserStats, - validators: [ - (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_stats 必须为对象' - ] - }, - storage_version: { - defaultValue: () => null, - validators: [ - (value) => value === null || typeof value === 'string' || 'storage_version 必须是字符串或 null' - ], - cloneOnRead: false - }, - data_restored: { - defaultValue: () => false, - validators: [ - (value) => typeof value === 'boolean' || 'data_restored 必须是布尔值' - ], - cloneOnRead: false - }, - active_sessions: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'active_sessions 必须为数组' - ] - }, - temp_practice_records: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'temp_practice_records 必须为数组' - ] - }, - interrupted_records: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'interrupted_records 必须为数组' - ] - }, - exam_index: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'exam_index 必须为数组' - ] - }, - vocab_words: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'vocab_words 必须为数组' - ] - }, - vocab_user_config: { - defaultValue: createDefaultVocabConfig, - validators: [ - (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'vocab_user_config 必须为对象' - ] - }, - vocab_review_queue: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'vocab_review_queue 必须为数组' - ] - }, - vocab_list_reading_highlights: { - defaultValue: () => [], - validators: [ - (value) => ( - Array.isArray(value) - || (value && typeof value === 'object' && Array.isArray(value.words)) - ) || 'vocab_list_reading_highlights 必须为数组或词表对象' - ] - }, - legacy_practice_records_migrated: { - defaultValue: () => false, - validators: [ - (value) => typeof value === 'boolean' || 'legacy_practice_records_migrated 必须为布尔值' - ], - cloneOnRead: false - } - }); - - registry.register('practice', practiceRepo); - registry.register('settings', settingsRepo); - registry.register('backups', backupRepo); - registry.register('meta', metaRepo); - - const internalApi = { - get practice() { return practiceRepo; }, - get settings() { return settingsRepo; }, - get backups() { return backupRepo; }, - get meta() { return metaRepo; }, - transaction(names, handler) { - return registry.transaction(names, handler); - }, - runConsistencyChecks(names) { - return registry.runConsistencyChecks(names); - } - }; - window.PracticeCore.__installInternalRepositories(internalApi, { createInternalOptions }); - if (window.__installStorageInternalAccess) { - try { - delete window.__installStorageInternalAccess; - } catch (_) { - window.__installStorageInternalAccess = undefined; - } - } - const metaFacade = createMetaFacade(metaRepo); - const api = { - get settings() { return settingsRepo; }, - get backups() { return backupRepo; }, - get meta() { return metaFacade; }, - transaction(names, handler) { - const targetNames = Array.isArray(names) ? names : []; - if (targetNames.includes('practice')) { - throw new Error('practice_records transactions must go through PracticeRecordAPI'); - } - return registry.transaction(names, handler); - }, - runConsistencyChecks(names) { - const targetNames = Array.isArray(names) - ? names.filter((name) => name !== 'practice') - : undefined; - return registry.runConsistencyChecks(targetNames); - } - }; - const registryApi = window.StorageProviderRegistry; - if (registryApi && typeof registryApi.registerStorageProviders === 'function') { - registryApi.registerStorageProviders({ - repositories: api, - storageManager: window.storage || null, - persistentStore: window.persistentStore || null, - preferenceStore: window.preferenceStore || null - }); - } else { - window.dataRepositories = api; - } - - ExamData.registry = registry; - ExamData.createDefaultUserStats = createDefaultUserStats; - ExamData.createDefaultVocabConfig = createDefaultVocabConfig; - console.log('[data/index] 数据仓库初始化完成'); - } - - bootstrap(); -})(window); diff --git a/js/data/practiceRecordSource.js b/js/data/practiceRecordSource.js new file mode 100644 index 00000000..157980b4 --- /dev/null +++ b/js/data/practiceRecordSource.js @@ -0,0 +1,199 @@ +/** + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 + */ +(function initPracticeRecordSource(global) { + 'use strict'; + + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; + } + + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); + + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); + + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); + + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); + + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); + } + + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); + } + + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; + } + + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); + } + + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; + + if (demoSourceSet.has(readMetadataSource(record))) return false; + + return true; + } + + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } + + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } + + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); + + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } + + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; + } + + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; + } + return previewRecordIds.delete(normalizeId(recordId)); + } + + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); + } + + /** + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 + */ + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); + + global.PracticeRecordSource = api; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/data/repositories/backupRepository.js b/js/data/repositories/backupRepository.js deleted file mode 100644 index 185de712..00000000 --- a/js/data/repositories/backupRepository.js +++ /dev/null @@ -1,142 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - - function ensureArray(value) { - return Array.isArray(value) ? value : []; - } - - class BackupRepository extends BaseRepository { - constructor(dataSource, options = {}) { - super({ - dataSource, - key: options.key || 'manual_backups', - name: options.name || 'manual_backups', - defaultValue: () => [], - migrations: [ - (value) => ensureArray(value), - ...(options.migrations || []) - ], - validators: [ - (value) => Array.isArray(value) || 'manual_backups 必须是数组', - ...(options.validators || []) - ], - cloneOnRead: options.cloneOnRead !== false - }); - this.maxBackups = options.maxBackups || 20; - } - - normalizeBackup(backup) { - if (!backup || typeof backup !== 'object') { - throw new Error('备份数据必须是对象'); - } - const normalized = { ...backup }; - normalized.id = normalized.id ? String(normalized.id) : `backup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - normalized.timestamp = normalized.timestamp || new Date().toISOString(); - normalized.type = normalized.type || 'manual'; - normalized.version = normalized.version || '0.6.2-fix'; - normalized.data = normalized.data || {}; - normalized.size = normalized.size || JSON.stringify(normalized.data).length; - return normalized; - } - - validateBackup(backup) { - const errors = []; - if (!backup || typeof backup !== 'object') { - errors.push('备份必须是对象'); - } else { - if (!backup.id) { - errors.push('备份缺少 id'); - } - if (!backup.timestamp) { - errors.push('备份缺少 timestamp'); - } - if (!backup.data || typeof backup.data !== 'object') { - errors.push('备份缺少 data 对象'); - } - } - return { - isValid: errors.length === 0, - errors - }; - } - - _assertBackup(backup) { - const validation = this.validateBackup(backup); - if (!validation.isValid) { - const error = new Error(`[manual_backups] 备份无效: ${validation.errors.join(', ')}`); - error.validationErrors = validation.errors; - throw error; - } - return true; - } - - async list(options = {}) { - return await this.read({ ...options, clone: options.clone !== false }); - } - - async add(backup, options = {}) { - const normalized = this.normalizeBackup(backup); - this._assertBackup(normalized); - return this.dataSource.runTransaction(async (tx) => { - let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true })); - backups.unshift(normalized); - if (this.maxBackups && backups.length > this.maxBackups) { - backups = backups.slice(0, this.maxBackups); - } - await this.write(backups, { transaction: tx, skipValidation: true, clone: false }); - return normalized; - }, { label: 'backup-add' }); - } - - async saveAll(backups, options = {}) { - const prepared = ensureArray(backups).map((item) => { - const normalized = this.normalizeBackup(item); - this._assertBackup(normalized); - return normalized; - }); - await this.write(prepared, { ...options, skipValidation: true }); - return true; - } - - async delete(id, options = {}) { - if (!id) return false; - const targetId = String(id); - return this.dataSource.runTransaction(async (tx) => { - let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true })); - const next = backups.filter(backup => backup.id !== targetId); - const deleted = next.length !== backups.length; - if (deleted) { - await this.write(next, { transaction: tx, skipValidation: true, clone: false }); - } - return deleted; - }, { label: 'backup-delete' }); - } - - async getById(id, options = {}) { - if (!id) return null; - const backups = await this.read({ ...options, clone: true }); - return backups.find(backup => backup.id === String(id)) || null; - } - - async clear(options = {}) { - await this.write([], { ...options, skipValidation: true }); - return true; - } - - async prune(limit, options = {}) { - const max = typeof limit === 'number' && limit > 0 ? limit : this.maxBackups; - return this.dataSource.runTransaction(async (tx) => { - let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true })); - if (backups.length <= max) { - return backups.length; - } - const next = backups.slice(0, max); - await this.write(next, { transaction: tx, skipValidation: true, clone: false }); - return next.length; - }, { label: 'backup-prune' }); - } - } - - ExamData.BackupRepository = BackupRepository; -})(window); diff --git a/js/data/repositories/baseRepository.js b/js/data/repositories/baseRepository.js deleted file mode 100644 index 222530a1..00000000 --- a/js/data/repositories/baseRepository.js +++ /dev/null @@ -1,163 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - function cloneValue(value) { - if (value === null || value === undefined) { - return value; - } - if (typeof structuredClone === 'function') { - try { - return structuredClone(value); - } catch (_) { - // Fallback to JSON serialization below - } - } - try { - return JSON.parse(JSON.stringify(value)); - } catch (_) { - return value; - } - } - - class BaseRepository { - constructor(options) { - const { - dataSource, - key, - name, - defaultValue = null, - migrations = [], - validators = [], - cloneOnRead = true - } = options || {}; - - if (!dataSource) { - throw new Error('BaseRepository requires a dataSource instance'); - } - if (!key) { - throw new Error('BaseRepository requires a storage key'); - } - - this.dataSource = dataSource; - this.key = key; - this.name = name || key; - this.defaultValue = defaultValue; - this.migrations = Array.isArray(migrations) ? migrations.slice() : [migrations]; - this.validators = Array.isArray(validators) ? validators.slice() : [validators]; - this.cloneOnRead = cloneOnRead; - } - - _resolveDefaultValue(override) { - const candidate = override !== undefined ? override : this.defaultValue; - return typeof candidate === 'function' ? candidate() : candidate; - } - - async read(options = {}) { - const { transaction, defaultValue, skipValidation = false, clone = undefined } = options; - const resolvedDefault = this._resolveDefaultValue(defaultValue); - const sourceValue = transaction - ? await transaction.get(this.key, resolvedDefault) - : await this.dataSource.read(this.key, resolvedDefault); - - let value = sourceValue === undefined ? resolvedDefault : sourceValue; - value = await this.applyMigrations(value, { transaction }); - - if (!skipValidation) { - this.validate(value); - } - - if (clone === false || (!this.cloneOnRead && clone === undefined)) { - return value; - } - return cloneValue(value); - } - - async write(value, options = {}) { - const { transaction, skipValidation = false, clone = true } = options; - if (!skipValidation) { - this.validate(value); - } - const dataToPersist = clone ? cloneValue(value) : value; - if (transaction) { - transaction.set(this.key, dataToPersist); - return true; - } - await this.dataSource.write(this.key, dataToPersist); - return true; - } - - async remove(options = {}) { - const { transaction } = options; - if (transaction) { - transaction.remove(this.key); - return true; - } - await this.dataSource.remove(this.key); - return true; - } - - async applyMigrations(value, context = {}) { - let current = value; - for (const migration of this.migrations) { - if (typeof migration === 'function') { - current = await migration(current, { key: this.key, name: this.name, ...context }); - } - } - return current; - } - - validate(value) { - const errors = []; - for (const validator of this.validators) { - if (typeof validator !== 'function') { - continue; - } - try { - const result = validator(value); - if (result === false) { - errors.push(`${this.name} 数据验证失败`); - } else if (typeof result === 'string') { - errors.push(result); - } else if (result && typeof result === 'object') { - if (result.valid === false || result.isValid === false) { - errors.push(result.message || result.error || `${this.name} 数据验证失败`); - } - } - } catch (error) { - errors.push(error.message || String(error)); - } - } - if (errors.length > 0) { - const err = new Error(`[${this.name}] 数据验证失败: ${errors.join('; ')}`); - err.validationErrors = errors; - throw err; - } - return true; - } - - async runConsistencyCheck(options = {}) { - try { - const data = await this.read({ ...options, skipValidation: false }); - return { valid: true, data, errors: [] }; - } catch (error) { - const errors = error.validationErrors || [error.message || String(error)]; - return { valid: false, errors }; - } - } - - registerMigration(fn) { - if (typeof fn === 'function') { - this.migrations.push(fn); - } - } - - registerValidator(fn) { - if (typeof fn === 'function') { - this.validators.push(fn); - } - } - } - - ExamData.cloneValue = cloneValue; - ExamData.BaseRepository = BaseRepository; -})(window); diff --git a/js/data/repositories/dataRepositoryRegistry.js b/js/data/repositories/dataRepositoryRegistry.js deleted file mode 100644 index 2b24a1ad..00000000 --- a/js/data/repositories/dataRepositoryRegistry.js +++ /dev/null @@ -1,68 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - class DataRepositoryRegistry { - constructor(dataSource) { - if (!dataSource) { - throw new Error('DataRepositoryRegistry requires a dataSource instance'); - } - this.dataSource = dataSource; - this._repositories = new Map(); - } - - register(name, repository) { - if (!name) { - throw new Error('Repository name is required'); - } - if (!repository) { - throw new Error(`Repository instance missing for ${name}`); - } - this._repositories.set(name, repository); - } - - get(name) { - return this._repositories.get(name); - } - - listNames() { - return Array.from(this._repositories.keys()); - } - - async transaction(names, handler) { - if (typeof handler !== 'function') { - throw new Error('transaction handler must be a function'); - } - const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames(); - return this.dataSource.runTransaction(async (tx) => { - const scope = {}; - for (const name of targetNames) { - if (this._repositories.has(name)) { - scope[name] = this._repositories.get(name); - } - } - return handler(scope, tx); - }, { label: `registry:${targetNames.join(',')}` }); - } - - async runConsistencyChecks(names) { - const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames(); - const report = {}; - for (const name of targetNames) { - const repo = this._repositories.get(name); - if (repo && typeof repo.runConsistencyCheck === 'function') { - try { - report[name] = await repo.runConsistencyCheck(); - } catch (error) { - report[name] = { - valid: false, - errors: [error.message || String(error)] - }; - } - } - } - return report; - } - } - - ExamData.DataRepositoryRegistry = DataRepositoryRegistry; -})(window); diff --git a/js/data/repositories/metaRepository.js b/js/data/repositories/metaRepository.js deleted file mode 100644 index af3d41ab..00000000 --- a/js/data/repositories/metaRepository.js +++ /dev/null @@ -1,70 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - - class MetaRepository { - constructor(dataSource, definitions = {}) { - if (!dataSource) { - throw new Error('MetaRepository requires a dataSource instance'); - } - this.dataSource = dataSource; - this.repositories = new Map(); - Object.entries(definitions).forEach(([key, config]) => { - this.registerKey(key, config); - }); - } - - registerKey(key, config = {}) { - const repository = new BaseRepository({ - dataSource: this.dataSource, - key, - name: config.name || `meta:${key}`, - defaultValue: config.defaultValue !== undefined ? config.defaultValue : null, - migrations: config.migrations || [], - validators: config.validators || [], - cloneOnRead: config.cloneOnRead !== false - }); - this.repositories.set(key, repository); - return repository; - } - - _getRepo(key) { - const repo = this.repositories.get(key); - if (!repo) { - throw new Error(`MetaRepository 未注册键: ${key}`); - } - return repo; - } - - async get(key, defaultValue, options = {}) { - const repo = this._getRepo(key); - const resolvedDefault = defaultValue !== undefined ? defaultValue : undefined; - return repo.read({ ...options, defaultValue: resolvedDefault, clone: options.clone !== false }); - } - - async set(key, value, options = {}) { - const repo = this._getRepo(key); - await repo.write(value, { ...options, skipValidation: false, clone: options.clone !== false }); - return true; - } - - async remove(key, options = {}) { - const repo = this._getRepo(key); - await repo.remove(options); - return true; - } - - async runConsistencyCheck(keys) { - const targetKeys = Array.isArray(keys) && keys.length ? keys : Array.from(this.repositories.keys()); - const report = {}; - for (const key of targetKeys) { - const repo = this.repositories.get(key); - if (!repo) continue; - report[key] = await repo.runConsistencyCheck(); - } - return report; - } - } - - ExamData.MetaRepository = MetaRepository; -})(window); diff --git a/js/data/repositories/practiceRepository.js b/js/data/repositories/practiceRepository.js deleted file mode 100644 index 10dece85..00000000 --- a/js/data/repositories/practiceRepository.js +++ /dev/null @@ -1,206 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - - function ensureArray(value) { - return Array.isArray(value) ? value : []; - } - - class PracticeRepository extends BaseRepository { - constructor(dataSource, options = {}) { - super({ - dataSource, - key: options.key || 'practice_records', - name: options.name || 'practice_records', - defaultValue: () => [], - migrations: [ - (value) => ensureArray(value), - ...(options.migrations || []) - ], - validators: [ - (value) => Array.isArray(value) || 'practice_records 必须为数组', - ...(options.validators || []) - ], - cloneOnRead: options.cloneOnRead !== false - }); - this.maxRecords = options.maxRecords || 1000; - } - - normalizeRecord(record) { - if (!record || typeof record !== 'object') { - throw new Error('practice record 必须是对象'); - } - const normalized = { ...record }; - if (!normalized.id) { - normalized.id = `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - } else { - normalized.id = String(normalized.id); - } - return normalized; - } - - validatePracticeRecord(record) { - const errors = []; - if (!record || typeof record !== 'object') { - errors.push('记录必须是对象'); - } else { - if (!record.id || typeof record.id !== 'string') { - errors.push('记录缺少有效的 id'); - } - if (!record.type || typeof record.type !== 'string') { - errors.push('记录缺少有效的 type'); - } - if (record.score === undefined || record.score === null || typeof record.score !== 'number') { - errors.push('记录缺少有效的 score'); - } - if (record.score !== undefined && typeof record.score !== 'number') { - errors.push('score 必须是数字'); - } - if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') { - errors.push('totalQuestions 必须是数字'); - } - if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') { - errors.push('correctAnswers 必须是数字'); - } - if (record.duration !== undefined && typeof record.duration !== 'number') { - errors.push('duration 必须是数字'); - } - if (!record.date) { - errors.push('记录缺少有效的 date'); - } else if (Number.isNaN(new Date(record.date).getTime())) { - errors.push('date 格式无效'); - } - } - return { - isValid: errors.length === 0, - errors - }; - } - - _assertRecord(record) { - const validation = this.validatePracticeRecord(record); - if (!validation.isValid) { - const error = new Error(`[practice_records] 记录无效: ${validation.errors.join(', ')}`); - error.validationErrors = validation.errors; - throw error; - } - return true; - } - - async list(options = {}) { - return await this.read({ ...options, clone: options.clone !== false }); - } - - async getById(id, options = {}) { - const records = await this.read({ ...options, clone: true }); - return records.find(r => r.id === id) || null; - } - - async overwrite(records, options = {}) { - const list = ensureArray(records).map((record) => { - const normalized = this.normalizeRecord(record); - this._assertRecord(normalized); - return normalized; - }); - await this.write(list, { ...options, skipValidation: true }); - return true; - } - - async upsert(record, options = {}) { - const normalized = this.normalizeRecord(record); - this._assertRecord(normalized); - const merge = options.merge === true; - return this.dataSource.runTransaction(async (tx) => { - let records = await this.read({ transaction: tx, skipValidation: true, clone: true }); - records = ensureArray(records); - const index = records.findIndex(r => r.id === normalized.id); - if (index >= 0) { - records[index] = merge ? { ...records[index], ...normalized } : normalized; - } else { - records.unshift(normalized); - } - if (this.maxRecords && records.length > this.maxRecords) { - records = records.slice(0, this.maxRecords); - } - await this.write(records, { transaction: tx, skipValidation: true, clone: false }); - return normalized; - }, { label: 'practice-upsert' }); - } - - async removeById(id, options = {}) { - if (!id) return 0; - const removed = await this.removeByIds([id], options); - return removed; - } - - async removeByIds(ids, options = {}) { - const idSet = new Set((ids || []).filter(Boolean).map(String)); - if (idSet.size === 0) { - return 0; - } - return this.dataSource.runTransaction(async (tx) => { - let records = await this.read({ transaction: tx, skipValidation: true, clone: true }); - records = ensureArray(records); - const next = records.filter(record => !idSet.has(record.id)); - const removed = records.length - next.length; - if (removed > 0) { - await this.write(next, { transaction: tx, skipValidation: true, clone: false }); - } - return removed; - }, { label: 'practice-remove' }); - } - - async update(id, updates = {}, options = {}) { - if (!id) { - throw new Error('update 需要记录 id'); - } - if (!updates || typeof updates !== 'object') { - throw new Error('updates 必须是对象'); - } - return this.dataSource.runTransaction(async (tx) => { - let records = await this.read({ transaction: tx, skipValidation: true, clone: true }); - records = ensureArray(records); - const index = records.findIndex(record => record.id === String(id)); - if (index === -1) { - return null; - } - const updated = { ...records[index], ...updates }; - this._assertRecord(updated); - records[index] = updated; - await this.write(records, { transaction: tx, skipValidation: true, clone: false }); - return updated; - }, { label: 'practice-update' }); - } - - async count(options = {}) { - const records = await this.read({ ...options, clone: false, skipValidation: false }); - return Array.isArray(records) ? records.length : 0; - } - - async clear(options = {}) { - await this.write([], { ...options, skipValidation: true }); - return true; - } - - async runConsistencyCheck(options = {}) { - const report = await super.runConsistencyCheck(options); - if (!report.valid) { - return report; - } - const errors = []; - const records = ensureArray(report.data); - for (const record of records) { - const validation = this.validatePracticeRecord(record); - if (!validation.isValid) { - errors.push(`记录 ${record && record.id ? record.id : 'unknown'}: ${validation.errors.join(', ')}`); - } - } - if (errors.length > 0) { - return { valid: false, errors }; - } - return { valid: true, data: records, errors: [] }; - } - } - - ExamData.PracticeRepository = PracticeRepository; -})(window); diff --git a/js/data/repositories/settingsRepository.js b/js/data/repositories/settingsRepository.js deleted file mode 100644 index 4dc23201..00000000 --- a/js/data/repositories/settingsRepository.js +++ /dev/null @@ -1,81 +0,0 @@ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - - function ensureObject(value) { - return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - } - - class SettingsRepository extends BaseRepository { - constructor(dataSource, options = {}) { - super({ - dataSource, - key: options.key || 'user_settings', - name: options.name || 'user_settings', - defaultValue: () => ({}), - migrations: [ - (value) => ensureObject(value), - ...(options.migrations || []) - ], - validators: [ - (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_settings 必须是对象', - ...(options.validators || []) - ], - cloneOnRead: options.cloneOnRead !== false - }); - } - - async getAll(options = {}) { - return await this.read({ ...options, clone: options.clone !== false }); - } - - async saveAll(settings, options = {}) { - const prepared = ensureObject(settings); - await this.write(prepared, { ...options, skipValidation: false }); - return true; - } - - async get(key, defaultValue = null, options = {}) { - const settings = await this.read({ ...options, clone: true }); - if (Object.prototype.hasOwnProperty.call(settings, key)) { - return settings[key]; - } - return typeof defaultValue === 'function' ? defaultValue() : defaultValue; - } - - async set(key, value, options = {}) { - return this.merge({ [key]: value }, options); - } - - async merge(patch, options = {}) { - if (!patch || typeof patch !== 'object') { - throw new Error('merge 需要对象参数'); - } - return this.dataSource.runTransaction(async (tx) => { - const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true })); - const next = { ...current, ...patch }; - await this.write(next, { transaction: tx, skipValidation: false, clone: false }); - return next; - }, { label: 'settings-merge' }); - } - - async removeKey(key, options = {}) { - return this.dataSource.runTransaction(async (tx) => { - const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true })); - if (!Object.prototype.hasOwnProperty.call(current, key)) { - return current; - } - delete current[key]; - await this.write(current, { transaction: tx, skipValidation: false, clone: false }); - return current; - }, { label: 'settings-remove-key' }); - } - - async clear(options = {}) { - await this.write({}, { ...options, skipValidation: true }); - return true; - } - } - - ExamData.SettingsRepository = SettingsRepository; -})(window); diff --git a/js/data/v2/appData.js b/js/data/v2/appData.js new file mode 100644 index 00000000..37c06fff --- /dev/null +++ b/js/data/v2/appData.js @@ -0,0 +1,2656 @@ +(function installAppData(global) { + 'use strict'; + + const internals = global.__AppDataV2Internals; + if (!internals || typeof internals.DataKernel !== 'function') { + throw new Error('AppData v2 requires DataKernel'); + } + const { + DataKernel, + AppDataError, + catalog, + clone, + randomId, + nowIso, + checksum + } = internals; + const kernel = new DataKernel(); + const importPlans = new Map(); + const RECOVERY_KEYS = Object.freeze({ + activeSession: 'recovery.activeSessions', + draft: 'recovery.drafts', + interrupted: 'recovery.interrupted', + rejectedCompletion: 'recovery.rejectedCompletions' + }); + const PREFERENCE_FIELDS = Object.freeze({ + theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode', + resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay', + threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget', + consent: 'consent', logConfig: 'logConfig' + }); + const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + + function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function asArray(value) { return Array.isArray(value) ? value : []; } + function idOf(value, fields) { + for (const field of fields) { + if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]); + } + return ''; + } + function importedLibraryId(value, options = {}) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id && options.nullable) return null; + if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required'); + if (/^exam_index(?:_|$)/.test(id)) { + throw new AppDataError('VALIDATION', 'Unsupported library configuration id'); + } + return id; + } + function assertObject(value, message) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function assertArray(value, message) { + if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function jsonValue(value, label = 'value') { + try { + const serialized = JSON.stringify(value, (_key, current) => { + if (typeof current === 'bigint') return String(current); + if (typeof current === 'number' && !Number.isFinite(current)) return null; + return current; + }); + if (serialized === undefined) return null; + return JSON.parse(serialized); + } catch (error) { + throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message }); + } + } + function operationId(command, prefix, semanticPayload = command) { + const id = command && command.operationId ? String(command.operationId) : randomId(prefix); + jsonValue(semanticPayload, `${prefix} payload`); + return id; + } + function mutationOptions(command, prefix, semanticPayload, extra = {}) { + const source = asObject(command); + const payload = jsonValue(semanticPayload, `${prefix} payload`); + const intent = { command: prefix, payload }; + if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) { + intent.expectedRevision = source.expectedRevision; + } + return Object.assign({}, extra, { + operationId: operationId(source, prefix, payload), + intent + }); + } + function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) { + return mutationOptions(asObject(options), prefix, semanticPayload, extra); + } + function deterministicEntityId(prefix, operation) { + return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`; + } + function normalizeAccuracyRatio(value, label = 'accuracy') { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`); + } + return numeric > 1 ? numeric / 100 : numeric; + } + function defaultStats() { + return { + totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0, + reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + lastUpdated: nowIso() + }; + } + + function nonNegativeScalar(value) { + if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + } + + function firstNonNegativeScalar(...values) { + for (const value of values) { + const numeric = nonNegativeScalar(value); + if (numeric !== null) return numeric; + } + return null; + } + + function normalizeAnswerQuestionId(value, index = 0) { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || `q${index + 1}`; + } + + function normalizeAnswerValue(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); + if (value && typeof value === 'object') { + if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); + if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); + if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); + return jsonValue(value, 'practice answer'); + } + return typeof value === 'string' ? value : String(value); + } + + function normalizeAnswerMap(value) { + const normalized = {}; + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry === undefined || entry === null) return; + const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; + const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); + normalized[questionId] = normalizeAnswerValue( + hasOwn(item, 'answer') ? item.answer + : hasOwn(item, 'userAnswer') ? item.userAnswer + : hasOwn(item, 'value') ? item.value + : entry + ); + }); + return normalized; + } + if (!value || typeof value !== 'object') return normalized; + Object.entries(value).forEach(([questionId, answer], index) => { + if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; + normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); + }); + return normalized; + } + + function mergeAnswerMaps(...sources) { + const merged = {}; + for (const source of sources) { + const normalized = normalizeAnswerMap(source); + for (const [questionId, answer] of Object.entries(normalized)) { + if (!hasOwn(merged, questionId)) merged[questionId] = answer; + } + } + return merged; + } + + function canonicalizeAnswerSource(record) { + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const rawRealData = asObject(rawData.realData); + record.answers = mergeAnswerMaps( + record.answers, + record.answerMap, + record.answerList, + realData.answers, + realData.answerMap, + rawData.answers, + rawData.answerMap, + rawRealData.answers + ); + // These names remain accepted only at the compatibility boundary. The + // canonical detail layer owns one user-answer source: `answers`. + delete record.answerMap; + delete record.answerList; + } + + function normalizePracticeScoreFields(record) { + const scoreInfo = asObject(record.scoreInfo); + const realScoreInfo = asObject(asObject(record.realData).scoreInfo); + const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); + const overloadedCorrectAnswers = record.correctAnswers; + const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; + if (isAnswerMap) { + const existingMap = record.correctAnswerMap; + const overloadedObject = asObject(overloadedCorrectAnswers); + const existingObject = asObject(existingMap); + if (Object.keys(overloadedObject).length) { + record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); + } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { + record.correctAnswerMap = clone(overloadedCorrectAnswers); + } + } + + let correctAnswers = firstNonNegativeScalar( + overloadedCorrectAnswers, + record.correctAnswersCount, + record.correctCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct, + rawScoreInfo.correctAnswers, + rawScoreInfo.correct + ); + if (correctAnswers === null) { + const comparisons = asArray(record.answerComparison).length + ? asArray(record.answerComparison) + : asArray(asObject(record.realData).answerComparison); + if (comparisons.length) { + correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; + } + } + if (correctAnswers !== null) record.correctAnswers = correctAnswers; + else if (isAnswerMap) record.correctAnswers = 0; + + const totalQuestions = firstNonNegativeScalar( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total, + rawScoreInfo.totalQuestions, + rawScoreInfo.total + ); + if (totalQuestions !== null) record.totalQuestions = totalQuestions; + } + + function canonicalizeRecord(input) { + assertObject(input, 'practice record must be an object'); + const record = jsonValue(input, 'practice record'); + record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record'); + record.sessionId = idOf(record, ['sessionId']) || record.id; + record.timestamp = record.timestamp || record.completedAt || record.date || nowIso(); + record.completedAt = record.completedAt || record.timestamp; + record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice'; + record.metadata = asObject(record.metadata); + if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; + if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; + canonicalizeAnswerSource(record); + normalizePracticeScoreFields(record); + for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { + if (record[field] === undefined || record[field] === null || record[field] === '') continue; + const numeric = Number(record[field]); + if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`); + record[field] = numeric; + } + if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy'); + return jsonValue(record, 'canonical practice record'); + } + + function deriveQuestionTypeErrorCounts(source) { + const record = asObject(source); + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const counts = {}; + const addCount = (type, value) => { + const key = String(type || 'other').trim() || 'other'; + const amount = Math.max(0, Number(value) || 0); + if (amount > 0) counts[key] = (counts[key] || 0) + amount; + }; + const performanceSources = [ + record.questionTypePerformance, + realData.questionTypePerformance, + rawData.questionTypePerformance + ]; + let hasPerformanceData = false; + for (const performanceMap of performanceSources) { + if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; + let sourceHasPerformanceData = false; + for (const [type, value] of Object.entries(performanceMap)) { + const performance = asObject(value); + const total = Number(performance.total ?? performance.totalQuestions); + const correct = Number(performance.correct ?? performance.correctAnswers); + if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; + hasPerformanceData = true; + sourceHasPerformanceData = true; + addCount(type, total - correct); + } + if (sourceHasPerformanceData) break; + } + if (hasPerformanceData) return counts; + + const questionTypeMap = Object.assign( + {}, + asObject(rawData.questionTypeMap), + asObject(realData.questionTypeMap), + asObject(record.questionTypeMap) + ); + const normalizedTypeMap = {}; + for (const [questionId, type] of Object.entries(questionTypeMap)) { + normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; + } + const detailSources = [ + record.answerDetails, + asObject(record.scoreInfo).details, + realData.answerDetails, + asObject(realData.scoreInfo).details, + rawData.answerDetails, + asObject(rawData.scoreInfo).details + ]; + const seenQuestions = new Set(); + for (const details of detailSources) { + if (!details || typeof details !== 'object' || Array.isArray(details)) continue; + for (const [questionId, value] of Object.entries(details)) { + const detail = asObject(value); + const normalizedId = String(questionId).trim().toLowerCase(); + if (!normalizedId || seenQuestions.has(normalizedId)) continue; + let isWrong = detail.isCorrect === false || detail.correct === false; + if (detail.isCorrect === true || detail.correct === true) isWrong = false; + else if (!isWrong) { + const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); + const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); + isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); + } + if (!isWrong) continue; + seenQuestions.add(normalizedId); + addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); + } + } + return counts; + } + + function lightSuiteEntry(source, fallbackType = null) { + const entry = asObject(source); + const scoreInfo = asObject(entry.scoreInfo); + const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); + const metadata = asObject(entry.metadata); + const totalQuestions = firstNonNegativeScalar( + entry.totalQuestions, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total + ) ?? 0; + const correctAnswers = firstNonNegativeScalar( + entry.correctAnswers, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct + ) ?? 0; + const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'suite entry accuracy' + ) || 0; + const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0; + return jsonValue({ + id: entry.id || null, + sessionId: entry.sessionId || null, + examId: entry.examId || metadata.examId || null, + title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', + type: entry.type || metadata.type || metadata.examType || fallbackType || null, + date: entry.date || entry.completedAt || entry.timestamp || null, + duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage, + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + }, 'suite entry light projection'); + } + + function lightFromCanonical(source) { + const scoreInfo = asObject(source.scoreInfo); + const realScoreInfo = asObject(asObject(source.realData).scoreInfo); + const metadata = asObject(source.metadata); + const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const dataSource = hasOwn(source, 'dataSource') + ? source.dataSource + : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined); + const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0; + const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0; + const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'practice light accuracy' + ) || 0; + return jsonValue({ + id: source.id, + sessionId: source.sessionId, + examId: source.examId || source.metadata.examId || null, + title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '', + type: source.type, + mode: source.mode || source.practiceMode || null, + timestamp: source.timestamp, + completedAt: source.completedAt, + date: source.date || source.completedAt || source.timestamp || null, + startTime: source.startTime || null, + endTime: source.endTime || null, + duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, + score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` + // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 + // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 + dataSource, + // Summaries are list indexes. Keep only the metadata needed to filter, show a + // source label, or locate the originating library; details stay in their entity. + metadata: Object.fromEntries([ + // `source` must stay: PracticeRecordSource uses metadata.source demo markers + // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full. + 'examId', 'examTitle', 'title', 'type', 'category', 'frequency', + 'dataSource', 'source', 'libraryConfigurationId' + ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), + suite: source.suite == null ? null : clone(asObject(source.suite)), + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + }, 'practice light projection'); + } + + function projectLight(record) { + if (!record) return null; + return lightFromCanonical(canonicalizeRecord(record)); + } + + function firstNonEmpty(...values) { + let first; + for (const value of values) { + if (value === undefined || value === null) continue; + if (first === undefined) first = value; + if (Array.isArray(value) && value.length) return clone(value); + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value); + if (typeof value !== 'object') return clone(value); + } + return first === undefined ? {} : clone(first); + } + + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); + + function withoutRawData(value) { + if (Array.isArray(value)) return value.map(withoutRawData); + if (!value || typeof value !== 'object') return clone(value); + const clean = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item); + } + return clean; + } + + function splitPracticeRecord(input) { + const source = canonicalizeRecord(input); + const summary = lightFromCanonical(source); + const detail = { recordId: source.id }; + const annotations = { recordId: source.id }; + for (const [key, value] of Object.entries(source)) { + if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); + else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { + const next = Object.assign({}, asObject(entry)); + canonicalizeAnswerSource(next); + const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); + for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); + } + const annotation = {}; + for (const annotationKey of ANNOTATION_FIELDS) { + if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; } + if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey]; + if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey]; + } + delete next.realData; delete next.rawData; + if (Object.keys(annotation).length) { + if (!annotations.suiteEntries) annotations.suiteEntries = {}; + annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation; + } + return withoutRawData(next); + }); + else detail[key] = withoutRawData(value); + } + // Accept the old mirror only as an input normalization boundary; it is never persisted. + const realData = asObject(source.realData); const rawData = asObject(source.rawData); + for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); + } + for (const key of ANNOTATION_FIELDS) { + if (hasOwn(annotations, key)) continue; + if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]); + else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]); + } + return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') }; + } + + function joinPracticeRecord(summary, detail, annotations, projection = 'full') { + if (!summary) return null; + const mode = String(projection || 'full').toLowerCase(); + const light = clone(summary); + if (mode === 'light' || mode === 'summary') return light; + const joined = Object.assign({}, light, clone(asObject(detail))); + delete joined.recordId; + if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection'); + const annotationData = asObject(annotations); + for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value); + if (Array.isArray(joined.suiteEntries)) { + const suiteAnnotations = asObject(annotationData.suiteEntries); + joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {}))); + } + return jsonValue(joined, 'practice full projection'); + } + + function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); } + + // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。 + // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定, + // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败, + // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。 + const practiceRecordSource = global.PracticeRecordSource; + if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') { + throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)'); + } + const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord; + + function computeStats(records) { + const stats = defaultStats(); + for (const record of asArray(records).filter(isRealPracticeRecord)) { + const summary = projectLight(record); + const type = String(summary.type || '').toLowerCase(); + const target = type.includes('listen') ? stats.listening : stats.reading; + stats.totalPractices += 1; + stats.totalQuestions += summary.totalQuestions; + stats.correctAnswers += summary.correctAnswers; + target.practices += 1; + target.questions += summary.totalQuestions; + target.correct += summary.correctAnswers; + } + stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0; + for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0; + stats.lastUpdated = nowIso(); + return stats; + } + + function validIso(value) { + if (value === null || value === undefined || value === '') return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function practiceType(record) { + const metadata = asObject(record.metadata); + const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType, + record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase(); + if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening'; + if (hints.includes('read')) return 'reading'; + return null; + } + + function accuracyRatio(record) { + const summary = lightFromCanonical(record); + const value = Number(summary.accuracy); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } + + function durationSeconds(record) { + const scoreInfo = asObject(record.scoreInfo); + const realData = asObject(record.realData); + const realScoreInfo = asObject(realData.scoreInfo); + for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; + } + return 0; + } + + function earlierUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function laterUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function computeAchievementProgress(records, manual, existing) { + const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord) + .map((record, index) => ({ + record, + index, + unlockedAt: validIso(record.completedAt || record.timestamp), + time: new Date(record.completedAt || record.timestamp).getTime() + })) + .sort((left, right) => { + const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER; + const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER; + return leftTime - rightTime || left.index - right.index; + }); + const candidates = {}; + const setThreshold = (id, list, count) => { + if (list.length >= count) candidates[id] = list[count - 1].unlockedAt; + }; + setThreshold('first_step', items, 1); + setThreshold('practice_bronze', items, 10); + setThreshold('practice_silver', items, 50); + setThreshold('practice_gold', items, 100); + setThreshold('practice_platinum', items, 200); + + const reading = items.filter((item) => practiceType(item.record) === 'reading'); + const listening = items.filter((item) => practiceType(item.record) === 'listening'); + setThreshold('reading_first', reading, 1); + setThreshold('reading_bronze', reading, 10); + setThreshold('reading_silver', reading, 50); + setThreshold('reading_gold', reading, 100); + setThreshold('listening_first', listening, 1); + setThreshold('listening_bronze', listening, 10); + setThreshold('listening_silver', listening, 50); + setThreshold('listening_gold', listening, 100); + if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt); + if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt); + + let cumulativeDuration = 0; + let cumulativeAccuracy = 0; + let perfectCount = 0; + let speedCount = 0; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const accuracy = accuracyRatio(item.record); + const duration = durationSeconds(item.record); + cumulativeDuration += duration; + cumulativeAccuracy += accuracy; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt; + if (accuracy >= 1) { + perfectCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt; + if (perfectCount === 3) candidates.perfect_three = item.unlockedAt; + if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt; + } + if (duration > 0 && duration <= 300 && accuracy > 0.8) { + speedCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt; + if (speedCount === 3) candidates.speed_three = item.unlockedAt; + if (speedCount === 10) candidates.speed_ten = item.unlockedAt; + } + } + + const dayItems = new Map(); + for (const item of items) { + if (!item.unlockedAt) continue; + const day = item.unlockedAt.slice(0, 10); + if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt); + } + const days = Array.from(dayItems.keys()).sort(); + let streak = 0; + let previousDay = null; + for (const day of days) { + const currentDay = new Date(`${day}T00:00:00.000Z`).getTime(); + streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1; + previousDay = currentDay; + if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day); + if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day); + if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day); + if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day); + } + + const progress = {}; + const mergeUnlocked = (source) => { + for (const [rawId, value] of Object.entries(asObject(source))) { + if (!value || rawId === 'updatedAt') continue; + const id = rawId; + const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null; + if (!progress[id]) progress[id] = { unlockedAt }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + }; + mergeUnlocked(existing); + mergeUnlocked(manual); + for (const [id, unlockedAt] of Object.entries(candidates)) { + if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + return jsonValue(progress, 'achievement progress'); + } + + // Entity records are authoritative. Projections are assembled on reads, never cached or + // scheduled as follow-up work; this keeps a successful write immediately observable. + async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } + + async function retryMergeConflict(options, task, maxAttempts = 3) { + const explicitRevision = hasOwn(options, 'expectedRevision'); + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) { + throw error; + } + } + } + throw lastError; + } + + async function readCollectionMeta(logicalKey) { + const meta = await kernel.read(logicalKey, { withMeta: true }); + return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 }; + } + + function retainBackupEntries(items, limit = 20, preserveIds = []) { + const cap = Math.max(1, Number(limit) || 20); + const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || '')); + const entries = asArray(items).filter(Boolean).sort(newestFirst); + const retained = []; + const retainedIds = new Set(); + const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean)); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap) break; + if (retainedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + return retained; + } + + function hasOwn(value, key) { + return Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); + } + + function normalizeLibraryConfigurationId(value) { + return importedLibraryId(value, { nullable: true }); + } + + async function practiceRecordWithLibraryProvenance(source, command, options = {}) { + assertObject(source, 'practice record must be an object'); + const record = jsonValue(source, 'practice record'); + const metadata = asObject(record.metadata); + let configurationId; + + if (hasOwn(command, 'libraryConfigurationId')) { + configurationId = command.libraryConfigurationId; + } else if (hasOwn(metadata, 'libraryConfigurationId')) { + configurationId = metadata.libraryConfigurationId; + } else if (hasOwn(record, 'libraryConfigurationId')) { + configurationId = record.libraryConfigurationId; + } else { + configurationId = await kernel.read('library.activeConfigurationId'); + } + + const normalizedId = normalizeLibraryConfigurationId(configurationId); + record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId }); + + if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) { + record.suiteEntries = record.suiteEntries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const next = jsonValue(entry, 'practice suite entry'); + const entryMetadata = asObject(next.metadata); + const entryId = hasOwn(entryMetadata, 'libraryConfigurationId') + ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId) + : normalizedId; + next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId }); + return next; + }); + } + + return record; + } + + function practiceRecordMatches(record, identities) { + const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean)); + if (!expected.size || !record || typeof record !== 'object') return false; + return ['id', 'recordId', 'sessionId'].some((field) => { + const value = record[field]; + return value !== undefined && value !== null && expected.has(String(value)); + }); + } + + function practiceLayerId(row) { + return String(row && (row.recordId || row.id || row.sessionId) || ''); + } + async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { + // The real kernel reads all three entity stores in one readonly + // IndexedDB transaction. Keep the fallback for deliberately minimal + // embedders and unit-test kernels that only expose the original methods. + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); + } + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + const summaries = ids === null + ? await kernel.listEntities('practiceSummaries', { withMeta }) + : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); + const targetIds = summaries.map(practiceLayerId).filter(Boolean); + const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) + .then((rows) => rows.filter(Boolean)); + return { + practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], + practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], + practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] + }; + } + async function practiceLayers(recordId, withMeta = false) { + const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + return { + summary: find('practiceSummaries'), + detail: find('practiceDetails'), + annotations: find('practiceAnnotations') + }; + } + async function suiteChildRecordIds(command, aggregateRecordId) { + const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); + const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); + if (sessionIds.size) { + const summaries = await kernel.listEntities('practiceSummaries'); + for (const summary of summaries) { + if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); + } + } + ids.delete(String(aggregateRecordId)); + return ids; + } + function entityRevision(row) { return row ? Number(row.revision) : 0; } + function practiceUpserts(recordId, layers, existing = {}) { + return [ + { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) }, + { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) }, + { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) } + ]; + } + async function joinedPractice(recordId, projection, snapshot = null) { + const mode = String(projection || 'full').toLowerCase(); + const layers = snapshot || await practiceProjectionSnapshot([recordId], false, + mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : null)); + const summary = asArray(layers.practiceSummaries) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (!summary) return null; + if (mode === 'light' || mode === 'summary') return clone(summary); + const detail = asArray(layers.practiceDetails) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); + const annotations = asArray(layers.practiceAnnotations) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + return joinPracticeRecord(summary, detail, annotations, mode); + } + function practiceSummaryTime(summary) { + const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); + return Number.isFinite(time) ? time : 0; + } + function isReadingInsightSummary(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => practiceType(entry) === 'reading'); + } + return practiceType(asObject(summary)) === 'reading'; + } + function needsQuestionTypeInsightBackfill(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => + practiceType(entry) === 'reading' + && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); + } + return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + } + const practice = Object.freeze({ + async list(options = {}) { + await ready; + const projection = String(options.projection || 'full').toLowerCase(); + const snapshot = await practiceProjectionSnapshot(null, false, + projection === 'light' || projection === 'summary' + ? ['practiceSummaries'] + : null); + const summaries = asArray(snapshot.practiceSummaries); + if (projection === 'light' || projection === 'summary') return summaries; + return (await Promise.all(summaries + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) + .filter(Boolean); + }, + async listInsights(options = {}) { + await ready; + const requestedLimit = Number(options.limit); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 50) + : 10; + const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); + const summaries = asArray(snapshot.practiceSummaries) + .filter(isReadingInsightSummary) + .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) + .slice(0, limit); + return summaries.map((summary) => { + if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); + const detail = asArray(snapshot.practiceDetails) + .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; + return detail + ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) + : clone(summary); + }); + }, + async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, + async completeAttempt(command) { + await ready; + const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command; + const mutation = mutationOptions(command, 'practice-complete', source); + const recordInput = await practiceRecordWithLibraryProvenance(source, command); + if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId); + const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities( + practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation)); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async finalizeSuite(command) { + await ready; assertObject(command, 'finalizeSuite command is required'); + const mutation = mutationOptions(command, 'practice-suite', command); + const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); + if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); + const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command, async () => { + const existing = await practiceLayers(recordId, true); + const children = await suiteChildRecordIds(command, recordId); + const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); + return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); + }); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async updateAnnotations(command) { + await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || ''); + return retryMergeConflict(command, async () => { + const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`); + if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`); + const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data))); + const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default'); + if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) { + if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`); + annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) }); + } else { + if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`); + annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) }); + Object.assign(annotations, clone(asObject(command.patch))); + } + return kernel.mutateEntities([{ + type: 'upsert', + store: 'practiceAnnotations', + recordId, + data: annotations, + expectedRevision: entityRevision(current.annotations) + }], mutationOptions(command, 'practice-annotations', command)); + }); + }, + async delete(command) { + await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required'); + const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId })); + return Object.assign({}, receipt, { deletedCount: 1 }); + }, + async deleteMany(command) { + await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean))); + if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id]))); + if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); + }, + async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, + projectLight, + projectDetail + }); + + const settings = Object.freeze({ + async getAll() { await ready; return kernel.read('settings.values'); }, + async patch(values, options = {}) { + await ready; assertObject(values, 'settings.patch requires an object'); + const mutation = optionsMutationOptions(options, 'settings-patch', values); + return retryMergeConflict(options, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + }); + }, + async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); } + }); + + const library = Object.freeze({ + async listConfigurations() { await ready; return kernel.read('library.configurations'); }, + async getActive() { await ready; return kernel.read('library.activeConfigurationId'); }, + async getIndex(configurationId) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + if (id === null) return []; + const indexes = await kernel.read('library.importedIndexes'); + return asArray(indexes[id]); + }, + async updateConfiguration(configuration, options = {}) { + await ready; assertObject(configuration, 'library.updateConfiguration requires an object'); + const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId'])); + const current = await kernel.read('library.configurations', { withMeta: true }); + const configs = asArray(current.data); + const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id); + const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id }); + if (index >= 0) configs[index] = next; else configs.push(next); + return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration)); + }, + async activate(configurationId, options = {}) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + const current = await kernel.read('library.activeConfigurationId', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id })); + }, + async import(command) { + await ready; assertObject(command, 'library.import requires a command'); + const id = importedLibraryId(command.id || command.configurationId || randomId('library')); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id); + configs.push(Object.assign({}, asObject(command.configuration), { id, key: id })); + const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) }); + return kernel.mutate([ + { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ], mutationOptions(command, 'library-import', command)); + }, + async remove(configurationId, options = {}) { + await ready; const id = importedLibraryId(configurationId); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true }); + const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id]; + const changes = [ + { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ]; + if (String(activeMeta.data || '') === id) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 }); + } + return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id })); + }, + async resolveIndex() { + await ready; + const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]); + return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]); + } + }); + + function recoveryKey(kind) { + const key = RECOVERY_KEYS[String(kind || '')]; + if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`); + return key; + } + // Recovery document TTL is an AppData domain rule, not a catalog policy field. + const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000; + function recoveryTimestamp(item) { + for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) { + const parsed = Date.parse(item && item[field]); + if (Number.isFinite(parsed)) return parsed; + } + return null; + } + async function pruneRecoveryKey(logicalKey) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await kernel.read(logicalKey, { withMeta: true }); + const items = asArray(current.data); + const cutoff = Date.now() - RECOVERY_TTL_MS; + const retained = items.filter((item) => { + const timestamp = recoveryTimestamp(item); + return timestamp === null || timestamp > cutoff; + }); + if (retained.length === items.length) return items; + try { + await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], { + operationId: randomId('recovery-ttl') + }); + return retained; + } catch (error) { + if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error; + } + } + return kernel.read(logicalKey); + } + async function cleanupExpiredRecovery() { + for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey); + } + const windowSession = Object.freeze({ + save(name, value) { + if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable'); + const logicalName = String(name || 'default'); + const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) }; + global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload)); + return true; + }, + get(name) { + if (!global.sessionStorage) return null; + const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + if (!raw) return null; + const payload = JSON.parse(raw); + return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null; + }, + discard(name) { + if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + return true; + } + }); + + const recoveryMutationTails = new Map(); + function enqueueRecoveryMutation(logicalKey, task) { + const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve(); + const result = previous.then(task, task); + recoveryMutationTails.set(logicalKey, result.catch(() => undefined)); + return result; + } + + async function readRecovery(kind, id) { + await ready; + const items = await pruneRecoveryKey(recoveryKey(kind)); + return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null; + } + async function saveRecovery(kind, value, options = {}) { + await ready; assertObject(value, `recovery ${kind} value must be an object`); + const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value); + const key = recoveryKey(kind); + const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId); + const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() }); + const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + if (index >= 0) current.items[index] = item; else current.items.push(item); + return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation); + })); + const committedItem = (await kernel.read(key)) + .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + return Object.assign({}, receipt, { item: clone(committedItem || item) }); + } + async function discardRecovery(kind, id, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id)); + return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation); + })); + } + async function clearRecovery(kind, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) { + throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision }); + } + return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation); + })); + } + async function clearAllRecovery(options = {}) { + const results = {}; + for (const kind of Object.keys(RECOVERY_KEYS)) { + results[kind] = await clearRecovery(kind, options); + } + return results; + } + const recovery = Object.freeze({ + windowSession, + async clear(options = {}) { return clearAllRecovery(options); }, + async listActiveSessions() { return readRecovery('activeSession'); }, + async getActiveSession(id) { return readRecovery('activeSession', id); }, + async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); }, + async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async listDrafts() { return readRecovery('draft'); }, + async getDraft(id) { return readRecovery('draft', id); }, + async saveDraft(value, options) { return saveRecovery('draft', value, options); }, + async discardDraft(id, options) { return discardRecovery('draft', id, options); }, + async listInterrupted() { return readRecovery('interrupted'); }, + async getInterrupted(id) { return readRecovery('interrupted', id); }, + async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); }, + async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); }, + async listRejectedCompletions() { return readRecovery('rejectedCompletion'); }, + async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); }, + async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); }, + async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); } + }); + + function isImportableEntry(entry) { + return entry + && entry.classification !== 'system' + && entry.classification !== 'session' + && entry.import !== 'ignore'; + } + + function isPlainImportObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function isV2SnapshotShape(parsed) { + return isPlainImportObject(parsed) + && parsed.format === 'ielts-atlas-data-v2' + && isPlainImportObject(parsed.envelopes) + && isPlainImportObject(parsed.entities); + } + + const POISONED_V2_WRAPPER_ALIASES = Object.freeze({ + 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']), + 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']), + 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state']) + }); + const LIBRARY_IMPORT_KEYS = Object.freeze([ + 'library.configurations', + 'library.importedIndexes', + 'library.activeConfigurationId' + ]); + + function parseLegacyImportValue(value) { + if (typeof internals.parseLegacyValue !== 'function') { + throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); + } + return internals.parseLegacyValue(value); + } + + function decodePoisonedDocument(logicalKey, wrapped) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; + if (!aliases || !isPlainImportObject(wrapped) + || !Object.prototype.hasOwnProperty.call(wrapped, 'key') + || !Object.prototype.hasOwnProperty.call(wrapped, 'value') + || !aliases.includes(String(wrapped.key))) { + return { matched: false, value: null }; + } + const decoded = parseLegacyImportValue(wrapped.value); + if (!isPlainImportObject(decoded)) return { matched: true, value: null }; + const overlay = {}; + for (const [key, value] of Object.entries(wrapped)) { + if (key === 'key' || key === 'value' || key === 'timestamp') continue; + overlay[key] = clone(value); + } + return { + matched: true, + value: Object.assign({}, decoded, overlay) + }; + } + + function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { + if (!envelope || envelope.state !== 'present') return envelope; + const wrapped = envelope.data; + const decoded = decodePoisonedDocument(logicalKey, wrapped); + if (!decoded.matched || !decoded.value) return envelope; + const entry = catalog.get(logicalKey); + const next = internals.makeEnvelope(entry, decoded.value, { + revision: Number(envelope.revision) || 1, + operationId: String(envelope.operationId || randomId('import-repair')), + updatedAt: envelope.updatedAt + }); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + return next; + } + + function validateLibraryImportBundle(envelopes) { + const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (!presentKeys.length) return { valid: true, presentKeys }; + if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { + return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; + } + const values = {}; + for (const key of LIBRARY_IMPORT_KEYS) { + const envelope = envelopes[key]; + values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; + } + const configurations = asArray(values['library.configurations']); + const indexes = asObject(values['library.importedIndexes']); + const configurationIds = new Set(); + for (const configuration of configurations) { + const source = asObject(configuration); + const id = idOf(source, ['id', 'key', 'configId']); + if (!id || !acceptedLibraryId(id) || source.builtIn === true + || !Array.isArray(indexes[id]) || !indexes[id].length) { + return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; + } + configurationIds.add(id); + } + for (const [id, index] of Object.entries(indexes)) { + if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { + return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; + } + } + const activeId = values['library.activeConfigurationId']; + if (activeId !== null && (!acceptedLibraryId(activeId) + || !configurationIds.has(String(activeId)) + || !Array.isArray(indexes[String(activeId)]) + || !indexes[String(activeId)].length)) { + return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; + } + return { valid: true, presentKeys }; + } + + function canonicalizeV2Import(parsed) { + const warnings = []; + const repairedKeys = []; + const ignoredKeys = []; + const envelopes = {}; + for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + if (logicalKey === 'library.activeConfigurationId' + && rawEnvelope && rawEnvelope.state === 'present' + && String(rawEnvelope.data) === '[object Object]') { + ignoredKeys.push(logicalKey); + warnings.push('Skipped poisoned active library id'); + continue; + } + const repairCount = repairedKeys.length; + const repaired = repairPoisonedImportEnvelope( + logicalKey, + clone(rawEnvelope), + repairedKeys, + warnings + ); + const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; + const isLegacyRowWrapper = isPlainImportObject(rawData) + && Object.prototype.hasOwnProperty.call(rawData, 'key') + && Object.prototype.hasOwnProperty.call(rawData, 'value') + && String(rawData.key || '').startsWith('exam_system_'); + if (isLegacyRowWrapper && repairedKeys.length === repairCount) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + envelopes[logicalKey] = repaired; + } + const library = parsed.scope === 'full' + ? validateLibraryImportBundle(envelopes) + : { valid: true, presentKeys: [] }; + if (!library.valid) { + for (const logicalKey of library.presentKeys) { + delete envelopes[logicalKey]; + ignoredKeys.push(logicalKey); + } + warnings.push(`Skipped unsafe library data: ${library.reason}`); + } + const exportableKeys = catalog.list() + .filter((entry) => entry.export === true && isImportableEntry(entry)) + .map((entry) => entry.logicalKey); + const missingKeys = parsed.scope === 'full' + ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) + : []; + if (parsed.scope === 'full' && missingKeys.length) { + warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); + } + return { + envelopes, + warnings, + repairedKeys, + ignoredKeys, + missingKeys, + declaredScope: parsed.scope, + effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, + trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length + ? 'trusted-full' + : 'degraded-partial' + }; + } + + function resolveImportReplaceFlags(options = {}) { + const source = asObject(options); + const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase(); + const replaceAll = source.replace === true; + return { + replaceDocuments: replaceAll, + // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge. + replacePractice: replaceAll || practiceMode === 'replace' + }; + } + + function pickFirstRecordArray(candidates) { + for (const candidate of asArray(candidates)) { + if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) { + return { source: candidate.source, records: candidate.records }; + } + } + return null; + } + + /** + * Historical v1 export shapes (opensource / pre-AppData-v2): + * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats } + * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? } + * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*) + * - bare array of records, or { records: [...] } + * Recognition only — no dual backend and no local store migration. + */ + function extractLegacyPracticeRecords(payload) { + const sources = []; + const add = (source, records) => { + if (Array.isArray(records) && records.some(isPlainImportObject)) { + sources.push({ source, records }); + } + }; + + if (Array.isArray(payload)) { + add('(root array)', payload); + } else if (isPlainImportObject(payload)) { + const preferred = pickFirstRecordArray([ + { source: 'practice_records', records: payload.practice_records }, + { source: 'practiceRecords', records: payload.practiceRecords }, + { source: 'records', records: payload.records } + ]); + if (preferred) add(preferred.source, preferred.records); + + const data = isPlainImportObject(payload.data) ? payload.data : null; + if (data) { + const nested = pickFirstRecordArray([ + { source: 'data.practice_records', records: data.practice_records }, + { source: 'data.practiceRecords', records: data.practiceRecords } + ]); + if (nested) add(nested.source, nested.records); + else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data); + else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data); + if (isPlainImportObject(data.exam_system_practice_records)) { + add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); + } + } + if (isPlainImportObject(payload.exam_system_practice_records)) { + add('exam_system_practice_records.data', payload.exam_system_practice_records.data); + } + } + + const seen = new Set(); + const records = []; + for (const entry of sources) { + for (const item of asArray(entry.records)) { + if (!isPlainImportObject(item)) continue; + const identity = idOf(item, ['id', 'recordId', 'sessionId']); + if (identity) { + if (seen.has(identity)) continue; + seen.add(identity); + } + records.push(item); + } + } + return { + records, + sources: sources.map((entry) => entry.source) + }; + } + + function entityRowFromLayer(recordId, data, operationId) { + const payload = jsonValue(data, 'import practice entity'); + return { + recordId: String(recordId), + revision: 1, + operationId: String(operationId || `import-${recordId}`), + updatedAt: nowIso(), + data: payload, + checksum: checksum(payload) + }; + } + + function convertLegacyPracticeImport(payload) { + const extracted = extractLegacyPracticeRecords(payload); + if (!extracted.records.length) { + throw new AppDataError( + 'VALIDATION', + 'Import file is neither a v2 snapshot nor a recognizable v1 practice export' + ); + } + + const entities = { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + }; + const warnings = []; + let skipped = 0; + + for (const raw of extracted.records) { + try { + const layers = splitPracticeRecord(raw); + const recordId = layers.summary.id; + const operationId = `import-v1-${recordId}`; + entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId)); + entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId)); + entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId)); + } catch (error) { + skipped += 1; + warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`); + } + } + + if (!entities.practiceSummaries.length) { + throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized'); + } + + const accepted = entities.practiceSummaries.length; + return { + format: 'v1', + scope: 'partial', + envelopes: {}, + entities, + checksum: null, + warnings, + practiceSummary: { + accepted, + importedCount: accepted, + skippedCount: skipped, + sources: extracted.sources.slice() + } + }; + } + + function parseImportPayload(payload) { + let parsed; + try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); } + catch (error) { + if (error instanceof AppDataError) throw error; + throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message }); + } + + // Bare record arrays are a historical import convenience (UI file pickers). + if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed); + if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object'); + + if (isV2SnapshotShape(parsed)) { + if (Number(parsed.schemaVersion) !== Number(catalog.version)) { + throw new AppDataError('VALIDATION', 'Import schema version mismatch'); + } + if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) { + throw new AppDataError('VALIDATION', 'Import checksum mismatch'); + } + if (parsed.scope !== 'full' && parsed.scope !== 'partial') { + throw new AppDataError('VALIDATION', 'Import scope must be full or partial'); + } + const scope = parsed.scope; + for (const [store, rows] of Object.entries(parsed.entities)) { + if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) { + throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`); + } + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) { + throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + } + } + } + if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) { + throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer'); + } + const canonical = canonicalizeV2Import(parsed); + return { + format: 'v2', + scope: canonical.effectiveScope, + declaredScope: canonical.declaredScope, + envelopes: canonical.envelopes, + entities: parsed.entities, + checksum: parsed.checksum, + warnings: canonical.warnings, + practiceSummary: null, + repairedKeys: canonical.repairedKeys, + ignoredKeys: canonical.ignoredKeys, + missingKeys: canonical.missingKeys, + trust: canonical.trust + }; + } + + // Explicit but malformed v2 claims must not fall through to legacy parsers. + if (parsed.format === 'ielts-atlas-data-v2') { + throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported'); + } + + return convertLegacyPracticeImport(parsed); + } + + function collectionIdentityFields(logicalKey) { + if (logicalKey === 'library.configurations') return ['id', 'key', 'configId']; + if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId']; + if (logicalKey === 'backups.entries') return ['id']; + if (logicalKey === 'vocab.words') return ['id', 'word', 'key']; + if (logicalKey === 'goals.items') return ['id', 'goalId']; + return ['id', 'sessionId', 'recordId']; + } + + function collectionIdentity(logicalKey, value) { + const identity = idOf(value, collectionIdentityFields(logicalKey)); + return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity; + } + + function mergeCollection(existing, incoming, logicalKey) { + const result = asArray(existing).map((item) => clone(item)); + const positions = new Map(); + result.forEach((item, index) => { + const identity = collectionIdentity(logicalKey, item); + if (identity) positions.set(identity, index); + }); + for (const rawItem of asArray(incoming)) { + const item = jsonValue(rawItem, `${logicalKey} item`); + const identity = collectionIdentity(logicalKey, item); + if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`); + if (positions.has(identity)) result[positions.get(identity)] = item; + else { + positions.set(identity, result.length); + result.push(item); + } + } + return result; + } + + function mergeImportValue(entry, existing, incoming) { + const policy = entry.import; + if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey); + if (policy === 'patch') { + if (Array.isArray(existing) || Array.isArray(incoming)) { + // Array-shaped keys should use merge-by-id; treat accidental patch as replace. + return clone(incoming); + } + return Object.assign({}, asObject(existing), asObject(incoming)); + } + if (policy === 'replace') return clone(incoming); + throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`); + } + + async function currentEntitySnapshot() { + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(null, { withMeta: true }); + } + const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); + const result = {}; + for (const store of PRACTICE_ENTITY_STORES) { + if (store === 'practiceSummaries') result[store] = summaries; + else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean); + } + return result; + } + function practiceEntityIds(rows) { + return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean)); + } + function assertPracticeEntitySetsMatch(entities, message) { + const expected = practiceEntityIds(entities.practiceSummaries); + for (const store of PRACTICE_ENTITY_STORES.slice(1)) { + const actual = practiceEntityIds(entities[store]); + if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) { + throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', { + counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size])) + }); + } + } + } + async function createImportPlan(parsed, options = {}) { + const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options); + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} }; + const revisionToken = { documents: {}, entities: {} }; + const keys = []; const clearedKeys = []; + const warnings = asArray(parsed.warnings).map(String); + for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue; + if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`); + if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) { + warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); + continue; + } + const currentRead = await kernel.read(logicalKey, { withMeta: true }); + const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; + const currentEnvelope = currentRead && currentRead.envelope; + revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + let next = envelope; + if (!replaceDocuments && envelope.state === 'present') { + next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + } + snapshot.envelopes[logicalKey] = next; + keys.push(logicalKey); + if (next.state === 'cleared') clearedKeys.push(logicalKey); + } + + // Any successful practice import installs all three stores together. Merge + // may update a subset only when the final recordId sets remain identical. + const sourceStores = Object.keys(asObject(parsed.entities)); + let practiceExistingCount = null; + let practiceIncomingCount = null; + if (sourceStores.length) { + if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) { + throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations'); + } + const current = await currentEntitySnapshot(); + revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries( + asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0]) + )])); + practiceExistingCount = asArray(current.practiceSummaries).length; + practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length; + const existing = replacePractice + ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []])) + : current; + for (const store of PRACTICE_ENTITY_STORES) { + const rows = asArray(existing[store]).map(clone); + const positions = new Map(rows.map((row, index) => [String(row.recordId), index])); + for (const row of asArray(parsed.entities[store])) { + if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + const index = positions.get(String(row.recordId)); + if (index === undefined) { + positions.set(String(row.recordId), rows.length); + rows.push(clone(row)); + } else rows[index] = clone(row); + } + snapshot.entities[store] = rows; + } + assertPracticeEntitySetsMatch(snapshot.entities); + } + + snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities }); + const practiceSummary = parsed.practiceSummary + ? clone(parsed.practiceSummary) + : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries') + ? { + accepted: Number(practiceIncomingCount) || 0, + importedCount: Number(practiceIncomingCount) || 0, + skippedCount: 0, + existingCount: Number(practiceExistingCount) || 0, + incomingCount: Number(practiceIncomingCount) || 0, + finalCount: asArray(snapshot.entities.practiceSummaries).length, + removedCount: Math.max(0, (Number(practiceExistingCount) || 0) + - asArray(snapshot.entities.practiceSummaries).length) + } + : null); + if (practiceSummary && practiceSummary.existingCount === undefined) { + practiceSummary.existingCount = Number(practiceExistingCount) || 0; + practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0; + practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length; + practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount); + } + const destructive = clearedKeys.length > 0 + || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0); + return { + snapshot, + keys, + clearedKeys, + warnings, + practiceSummary, + destructive, + resetJournal: replaceDocuments && replacePractice, + revisionToken, + diagnostics: { + format: parsed.format, + replaceDocuments, + replacePractice, + declaredScope: parsed.declaredScope || parsed.scope, + effectiveScope: parsed.scope, + trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'), + missingKeys: clone(parsed.missingKeys || []), + repairedKeys: clone(parsed.repairedKeys || []), + ignoredKeys: clone(parsed.ignoredKeys || []) + } + }; + } + async function createRestoreSnapshot(backup) { + const parsed = parseImportPayload(asObject(backup && backup.data)); + if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups'); + if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch'); + return (await createImportPlan(parsed, { replace: true })).snapshot; + } + + const backups = Object.freeze({ + onDataCommitted(listener) { return kernel.onCommitted(listener); }, + async getSettings() { await ready; return kernel.read('backups.settings'); }, + async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); }, + async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); }, + async getImportHistory() { await ready; return kernel.read('backups.importHistory'); }, + async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); }, + async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); }, + async create(options = {}) { + await ready; const current = await readCollectionMeta('backups.entries'); + const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' }); + const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup')); + const existing = current.items.find((item) => String(item.id) === String(backupId)); + if (existing) { + if (String(existing.operationId || '') === String(mutation.operationId) + && String(existing.type || 'manual') === String(options.type || 'manual')) { + return clone(existing); + } + throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, { + backupId: String(backupId) + }); + } + const snapshot = await kernel.exportSnapshot(); + const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum }; + current.items.unshift(backup); + current.items = retainBackupEntries(current.items, 20, options.preserveIds); + await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation); + const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId)); + return clone(committed || backup); + }, + async list() { await ready; return kernel.read('backups.entries'); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); }, + async export(options = {}) { + await ready; + if (options.backupId !== undefined && options.backupId !== null) { + const backupId = String(options.backupId); + const stored = asArray(await kernel.read('backups.entries')) + .find((item) => String(item && item.id) === backupId); + if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`); + const portable = jsonValue(stored, 'stored backup export'); + if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) { + throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`); + } + return portable; + } + const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null; + const logicalKeys = domains + ? catalog.list() + .filter((entry) => domains.has(entry.owner) && entry.export === true) + .map((entry) => entry.logicalKey) + : null; + const entityStores = !domains || domains.has('practice') + ? undefined + : []; + return kernel.exportSnapshot(Object.assign( + logicalKeys ? { logicalKeys } : {}, + entityStores ? { entityStores } : {} + )); + }, + async previewImport(payload, options = {}) { + await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan'); + const cutoff = Date.now() - (30 * 60 * 1000); + for (const [id, existing] of importPlans) { + if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id); + } + const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) }; + importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive }; + }, + async commitImport(planId, options = {}) { + await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`); + if (plan.destructive && options.confirmDestructive !== true) { + throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation'); + } + const mutation = optionsMutationOptions(options, 'import-commit', { + planId: plan.id, + signature: plan.signature + }, { warnings: plan.warnings }); + const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, { + resetJournal: plan.resetJournal === true, + expectedRevisionToken: plan.revisionToken + })); + importPlans.delete(String(planId)); + return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) }); + }, + async restore(id, options = {}) { + await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id)); + if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`); + const snapshot = await createRestoreSnapshot(backup); + const restoreMutation = optionsMutationOptions(options, 'backup-restore', { + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }, { resetJournal: true }); + const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`; + const preRestoreBackupId = `pre_restore_${checksum({ + operationId: restoreMutation.operationId, + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }).replace(/[^a-z0-9]/gi, '')}`; + const preRestoreBackup = await backups.create({ + id: preRestoreBackupId, + operationId: preRestoreOperationId, + type: 'pre-restore', + preserveIds: [String(id)] + }); + const receipt = await kernel.installSnapshot(snapshot, restoreMutation); + return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id }); + } + }); + + let vocabMutationTail = Promise.resolve(); + function enqueueVocabMutation(task) { + const result = vocabMutationTail.then(task, task); + vocabMutationTail = result.catch(() => undefined); + return result; + } + function retryVocabMutation(options, task) { + return enqueueVocabMutation(() => retryMergeConflict(options, task)); + } + + const vocab = Object.freeze({ + async listWords() { await ready; return kernel.read('vocab.words'); }, + async saveWords(words, options = {}) { + await ready; assertArray(words, 'vocab.saveWords requires an array'); + const mutation = optionsMutationOptions(options, 'vocab-words', words); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.words', { withMeta: true }); + return mutateAndProject([{ + logicalKey: 'vocab.words', + data: words, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async getConfig() { await ready; return kernel.read('vocab.userConfig'); }, + async setConfig(config, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'vocab-config', config); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: asObject(config), + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async patchConfig(patch, options = {}) { + await ready; assertObject(patch, 'vocab.patchConfig requires an object'); + const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), clone(patch)); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); }, + async listCollections() { await ready; return kernel.read('vocab.lists'); }, + async saveCollection(id, value, options = {}) { + await ready; + const collectionId = String(id); + const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async saveCollections(values, options = {}) { + await ready; + assertObject(values, 'vocab.saveCollections requires an object'); + const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)])); + const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), upserts); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async upsertCollectionWord(collectionId, word, options = {}) { + await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word'); + const id = String(collectionId || ''); + if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required'); + const identity = String(word.word || word.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + const existing = collections[id]; + const list = existing && typeof existing === 'object' && !Array.isArray(existing) + ? Object.assign({}, clone(existing), { words: asArray(existing.words) }) + : { id, words: asArray(existing) }; + const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity); + const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() }); + if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt; + if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); + list.updatedAt = nowIso(); + collections[id] = list; + const receipt = await mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(nextWord) }); + }); + }, + async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; }, + async replaceListWords(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceListWords requires a command'); + const id = String(command.listId || 'default'); const words = asArray(command.words); + if (id === 'default') return this.saveWords(words, options); + const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async mergeListWords(command, options = {}) { + await ready; + assertObject(command, 'vocab.mergeListWords requires a command'); + const listId = String(command.listId || 'default'); + const incoming = asArray(command.words); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions(options, 'vocab-words-merge', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data)); + const storedList = listId === 'default' + ? asArray(current.data) + : (function readStoredCollection() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? asArray(collection.words) + : asArray(collection); + }()); + const merged = storedList.map((word) => clone(word)); + const positions = new Map(); + merged.forEach((word, index) => { + const identity = String(word && (word.word || word.id) || '').trim().toLowerCase(); + if (identity) positions.set(identity, index); + }); + let addedCount = 0; + let updatedCount = 0; + for (const rawWord of incoming) { + assertObject(rawWord, 'vocab.mergeListWords entries must be objects'); + const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + if (!positions.has(identity)) { + positions.set(identity, merged.length); + merged.push(clone(rawWord)); + addedCount += 1; + continue; + } + const index = positions.get(identity); + const existing = asObject(merged[index]); + const patch = {}; + if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim(); + if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim(); + if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq; + merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() }); + updatedCount += 1; + } + const data = listId === 'default' + ? merged + : Object.assign({}, collections, { + [listId]: Object.assign( + {}, + (function collectionBaseForWrite() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? clone(collection) + : {}; + }()), + { id: listId, words: merged, updatedAt: nowIso() } + ) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount }); + }); + }, + async patchWord(command, options = {}) { + await ready; assertObject(command, 'vocab.patchWord requires a command'); + const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || ''); + if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required'); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions( + Object.assign({}, options, { operationId: command.operationId || options.operationId }), + 'vocab-word-patch', + command + ); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : asObject(current.data); + const list = listId === 'default' + ? asArray(current.data) + : asArray(asObject(collections[listId]).words); + const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId); + if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`); + const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() }); + const next = list.slice(); next[index] = updated; + const data = listId === 'default' + ? next + : Object.assign({}, collections, { + [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(updated) }); + }); + }, + async replaceProgress(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceProgress requires a command'); + const listId = String(command.listId || 'default'); const words = asArray(command.words); + const mutation = optionsMutationOptions(options, 'vocab-progress', command); + return retryVocabMutation(options, async () => { + const configMeta = await kernel.read('vocab.userConfig', { withMeta: true }); + const changes = [{ + logicalKey: 'vocab.userConfig', + data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }), + expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0 + }]; + if (listId === 'default') { + const wordsMeta = await kernel.read('vocab.words', { withMeta: true }); + changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 }); + } else { + const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data)); + lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); + changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); + } + return mutateAndProject(changes, mutation); + }); + } + }); + + async function readPreferences() { await ready; return kernel.read('preferences.values'); } + let preferenceMutationTail = Promise.resolve(); + function enqueuePreferenceMutation(task) { + const result = preferenceMutationTail.then(task, task); + preferenceMutationTail = result.catch(() => undefined); + return result; + } + async function writePreference(field, value, options = {}) { + const mutation = optionsMutationOptions(options, 'preference-set', { field, value }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [field]: clone(value) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + async function patchPreference(field, patch, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const values = asObject(current.data); + const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + const preferences = Object.freeze({ + async getAll() { return readPreferences(); }, + async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); }, + async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); }, + async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); }, + async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); }, + async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); } + ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); }, + async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); }, + async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); }, + async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); }, + async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); }, + async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); }, + async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); }, + async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); } + }); + + const goals = Object.freeze({ + async list() { await ready; return kernel.read('goals.items'); }, + async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); } + }); + + function deliveryTimestamp(value) { + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN; + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function mergeDeliveryAcknowledgements(current, incoming) { + const merged = Object.assign({}, asObject(current)); + for (const [id, value] of Object.entries(asObject(incoming))) { + const key = String(id).trim(); + if (!key) continue; + const previous = deliveryTimestamp(merged[key]); + const next = deliveryTimestamp(value); + if (!hasOwn(merged, key) || (next && (!previous || next < previous))) { + merged[key] = next; + } else if (previous) { + merged[key] = previous; + } else { + merged[key] = null; + } + } + return merged; + } + + const achievements = Object.freeze({ + async getAll() { + await ready; + const progress = await retryMergeConflict({}, async () => { + const [summaries, manual, current] = await Promise.all([ + kernel.listEntities('practiceSummaries'), + kernel.read('achievements.manual'), + kernel.read('achievements.progress', { withMeta: true }) + ]); + const projected = asObject(computeAchievementProgress(summaries, manual, current.data)); + if (checksum(projected) !== checksum(asObject(current.data))) { + await kernel.mutate([{ + logicalKey: 'achievements.progress', + data: projected, + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], { + operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}` + }); + } + return projected; + }, 5); + if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh; + Object.defineProperty(progress, 'fresh', { value: true, enumerable: false }); + return progress; + }, + async retryPending() { return achievements.getAll(); }, + async acknowledgeDelivery(unlocked, options = {}) { + await ready; + assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object'); + const requested = clone(unlocked); + const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested); + return retryMergeConflict({}, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + const settingsValue = asObject(current.data); + const delivery = asObject(settingsValue.achievementDelivery); + const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested); + return kernel.mutate([{ + logicalKey: 'settings.values', + data: Object.assign({}, settingsValue, { + achievementDelivery: { version: 1, acknowledged } + }), + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], mutation); + }, 5); + }, + async getManualState() { await ready; return kernel.read('achievements.manual'); } + }); + + const LEGACY_DOCUMENT_ALIASES = Object.freeze({ + 'settings.values': ['user_settings', 'settings', 'system_settings'], + 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'], + 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'], + 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'], + 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'], + 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'], + 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], + 'achievements.manual': ['achievement_manual_state', 'user_achievements'] + }); + const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ + 'recovery.activeSessions', + 'recovery.drafts', + 'recovery.interrupted', + 'recovery.rejectedCompletions' + ]); + const LEGACY_PREFERENCE_ALIASES = Object.freeze({ + theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', + practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', + ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' + }); + + function legacyRecordArray(value) { + return Array.isArray(value) ? value : asArray(asObject(value).data); + } + function mergeLegacyExternalBackup(legacyValue, externalValue) { + const legacy = Object.assign({}, asObject(legacyValue)); + const external = asObject(externalValue); + const externalRecordKey = ['practice_records', 'practiceRecords'] + .find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (externalRecordKey) { + const records = new Map(); + for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { + const recordId = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); + } + legacy.practice_records = Array.from(records.values()); + } + for (const [target, aliases] of Object.entries({ + user_stats: ['user_stats', 'userStats'], + exam_index: ['exam_index', 'examIndex'], + storage_version: ['storage_version', 'storageVersion'] + })) { + if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (alias) legacy[target] = clone(external[alias]); + } + return legacy; + } + + function acceptedLibraryId(value) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; + try { return importedLibraryId(id); } catch (_) { return null; } + } + + function remapLegacyLibraryId(value) { + return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + } + + async function migrateLegacyLibraryData(legacy) { + const [configMeta, indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.configurations', { withMeta: true }), + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const legacyIdMap = new Map(); + const indexes = {}; + const addLegacyIndex = (oldId, value) => { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; + const index = asArray(value); + if (!index.length) return; + const mappedId = remapLegacyLibraryId(oldId); + legacyIdMap.set(oldId, mappedId); + indexes[mappedId] = clone(index); + }; + + for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); + // Reconciliation is a union. A healthy current v2 value wins for the same + // deterministic library ID, while missing legacy libraries are restored. + for (const [id, value] of Object.entries(asObject(indexMeta.data))) { + if (/^exam_index_/.test(id)) addLegacyIndex(id, value); + else { + const acceptedId = acceptedLibraryId(id); + if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); + } + } + + const configurations = new Map(); + const addConfiguration = (configuration) => { + const source = asObject(configuration); + const oldId = idOf(source, ['id', 'key', 'configId']); + if (!oldId || oldId === 'exam_index') return; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + if (!id || !asArray(indexes[id]).length) return; + configurations.set(id, Object.assign({}, clone(source), { + id, + key: id, + examCount: indexes[id].length + })); + }; + asArray(legacy.exam_index_configurations).forEach(addConfiguration); + asArray(configMeta.data).forEach(addConfiguration); + for (const [oldId, id] of legacyIdMap) { + if (!configurations.has(id)) { + configurations.set(id, { + id, + key: id, + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' + }); + } + } + + const resolveActive = (value) => { + const oldId = value === null || value === undefined ? '' : String(value).trim(); + if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + return id && asArray(indexes[id]).length ? id : null; + }; + const currentRawActive = activeMeta.data; + let activeId = resolveActive(currentRawActive); + const currentIsExplicitDefault = Boolean(activeMeta.envelope) + && (currentRawActive === null || String(currentRawActive || '').trim() === ''); + if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { + activeId = resolveActive(legacy.active_exam_index_key); + } + + const nextConfigurations = Array.from(configurations.values()); + const changes = []; + if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { + changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); + } + if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { + changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); + } + if (activeId !== activeMeta.data) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); + } + if (changes.length) { + await kernel.mutate(changes, { + operationId: `legacy-library-repair-v2-${checksum(changes)}` + }); + } + } + + async function migrateLegacyData() { + // Unit embedders may provide a deliberately minimal kernel bootstrap. + if (typeof internals.readLegacyValues !== 'function') return; + const legacySource = await internals.readLegacyValues(); + if (legacySource && legacySource.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); + const migrationState = asObject(migrationMeta.data); + let externalBackup = null; + if (asObject(migrationState.externalBackupV1).status !== 'consumed' + && typeof internals.readLegacyExternalBackup === 'function') { + try { + externalBackup = await internals.readLegacyExternalBackup(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); + } + } + const legacy = externalBackup + ? mergeLegacyExternalBackup(legacySource, externalBackup) + : legacySource; + if (!legacy || !Object.keys(legacy).length) return; + + const documentMetas = {}; + for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { + documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); + } + const [indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const documentRepairs = []; + const poisonedDocumentKeys = []; + for (const [logicalKey, current] of Object.entries(documentMetas)) { + if (!current.envelope || current.envelope.state !== 'present') continue; + const decoded = decodePoisonedDocument(logicalKey, current.data); + if (!decoded.matched) continue; + poisonedDocumentKeys.push(logicalKey); + const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; + const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + const legacyValue = legacyAlias ? legacy[legacyAlias] : null; + let repairValue = decoded.value; + if (isPlainImportObject(legacyValue)) { + repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); + } + if (!repairValue) continue; + documentRepairs.push({ + logicalKey, + data: repairValue, + expectedRevision: Number(current.envelope.revision) + }); + } + const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => + isPlainImportObject(value) + && Object.prototype.hasOwnProperty.call(value, 'key') + && Object.prototype.hasOwnProperty.call(value, 'value') + && /^exam_system_exam_index_/.test(String(value.key || ''))); + const poisonedActive = String(activeMeta.data) === '[object Object]'; + const libraryPoisoned = poisonedIndex || poisonedActive; + const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; + + await migrateLegacyLibraryData(legacy); + if (documentRepairs.length) { + await kernel.mutate(documentRepairs, { + operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` + }); + } + + const changes = []; + for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { + const currentAudit = asObject(migrationState.v1ToV2); + if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { + continue; + } + const current = await kernel.getEnvelope(logicalKey); + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + if (!alias) continue; + const legacyValue = legacy[alias]; + if (!current) { + changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); + continue; + } + const isBadMigrationWrite = current.state === 'present' + && /^legacy-documents-/.test(String(current.operationId || '')); + if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { + changes.push({ + logicalKey, + data: legacyValue, + expectedRevision: Number(current.revision) + }); + continue; + } + const entry = catalog.get(logicalKey); + if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; + let merged; + try { + merged = mergeImportValue(entry, legacyValue, current.data); + } catch (error) { + if (global.console && console.warn) { + console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); + } + continue; + } + if (checksum(merged) !== checksum(current.data)) { + changes.push({ + logicalKey, + data: merged, + expectedRevision: Number(current.revision) + }); + } + } + if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { + const preferences = {}; + for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) { + if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue; + const path = target.split('.'); let cursor = preferences; + path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; }); + cursor[path[path.length - 1]] = clone(legacy[alias]); + } + if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 }); + } + if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { + changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); + } + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + const recordsValue = legacy.practice_records; + const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); + const operations = []; + let skippedRecords = 0; + const reconciledRecordIds = new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + let canonical; + let parts; + try { + const candidate = jsonValue(record, 'legacy practice record'); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { + candidate.id = `legacy_${index}_${internals.checksum(record)}`; + } + canonical = canonicalizeRecord(candidate); + parts = splitPracticeRecord(canonical); + } catch (error) { + skippedRecords += 1; + if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); + continue; + } + if (reconciledRecordIds.has(canonical.id)) continue; + reconciledRecordIds.add(canonical.id); + // Storage errors are not malformed records. Let them abort this repair so the + // completion marker is not written and the next startup can retry safely. + const existing = await practiceLayers(canonical.id, true); + if (existing.summary && existing.detail && existing.annotations) continue; + operations.push(...practiceUpserts(canonical.id, parts, existing)); + } + if (operations.length) { + await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + } + const migrationAudit = { + version: 4, + status: 'complete', + mode: 'persistent-reconcile', + sourceChecksum: checksum(legacy), + sourceRecordCount: records.length, + skippedRecordCount: skippedRecords + }; + const currentAudit = asObject(migrationState.v1ToV2); + const comparableCurrentAudit = { + version: currentAudit.version, + status: currentAudit.status, + mode: currentAudit.mode, + sourceChecksum: currentAudit.sourceChecksum, + sourceRecordCount: currentAudit.sourceRecordCount, + skippedRecordCount: currentAudit.skippedRecordCount + }; + const externalAudit = externalBackup ? { + version: 1, + status: 'consumed', + sourceChecksum: checksum(externalBackup) + } : null; + const currentExternalAudit = asObject(migrationState.externalBackupV1); + if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) + || (externalAudit && (currentExternalAudit.status !== externalAudit.status + || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { + const nextMigrationState = Object.assign({}, migrationState, { + v1ToV2: Object.assign({}, migrationAudit, { + completedAt: nowIso(), + poisonDetected, + poisonedDocumentKeys, + libraryPoisoned + }) + }); + if (externalAudit) { + nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); + } + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { + operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` + }); + } + } + + const ready = kernel.initialize() + .then(async () => { + // Legacy migration and recovery cleanup are best-effort: a failure here + // (e.g. one malformed v1 record) must not brick the data layer for every + // read that awaits `ready`. Only a genuine backend init failure below is fatal. + try { + await migrateLegacyData(); + } catch (error) { + if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error); + } + try { + await cleanupExpiredRecovery(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error); + } + return true; + }) + .catch((error) => { + if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error); + throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed'); + }); + + const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements }; + Object.defineProperties(AppData, { + ready: { value: ready, enumerable: false }, + status: { value: () => kernel.status(), enumerable: false } + }); + Object.freeze(AppData); + Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false }); + if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) { + throw new Error('AppData v2 failed to close its internal bootstrap channel'); + } + if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) { + throw new Error('AppData v2 failed to close its catalog bootstrap channel'); + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/data/v2/dataCatalog.js b/js/data/v2/dataCatalog.js new file mode 100644 index 00000000..648ccad3 --- /dev/null +++ b/js/data/v2/dataCatalog.js @@ -0,0 +1,230 @@ +(function installDataCatalog(global) { + 'use strict'; + + const V2_SCHEMA_VERSION = 2; + + function clone(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + + function objectDefault() { return {}; } + function arrayDefault() { return []; } + function nullableDefault() { return null; } + function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; } + function normalizeObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {}; + } + function normalizeNullableString(value) { + return value === null || value === undefined || value === '' ? null : String(value); + } + function isArray(value) { return Array.isArray(value); } + function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } + function isNullableString(value) { return value === null || typeof value === 'string'; } + + const CATALOG_OWNERS = new Set([ + 'settings', 'library', 'recovery', 'backups', 'vocab', + 'preferences', 'goals', 'achievements', 'system', 'practice' + ]); + const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']); + const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']); + + function isNonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); + } + + function ownerFromKey(logicalKey) { + const dot = String(logicalKey || '').indexOf('.'); + return dot > 0 ? logicalKey.slice(0, dot) : ''; + } + + function freezeEntry(entry) { + const logicalKey = String(entry.logicalKey || ''); + const owner = ownerFromKey(logicalKey); + const next = Object.assign({}, entry, { + logicalKey, + owner, + schemaVersion: V2_SCHEMA_VERSION, + export: entry.export === true, + import: entry.import || 'ignore' + }); + return Object.freeze(next); + } + + // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations), + // not as document keys. import merge identity is resolved in AppData, not here. + const definitions = [ + { + logicalKey: 'settings.values', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.configurations', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'library.importedIndexes', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.activeConfigurationId', classification: 'authoritative', + defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString, + export: true, import: 'replace' + }, + { + logicalKey: 'recovery.activeSessions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.drafts', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.interrupted', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.windowSession', classification: 'session', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.entries', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'merge-by-id' + }, + { + logicalKey: 'backups.settings', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'backups.exportHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.importHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'vocab.words', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'vocab.userConfig', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'vocab.lists', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'preferences.values', classification: 'preference', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'goals.items', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'achievements.manual', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'achievements.progress', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'system.migrations', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'system.operationJournal', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + } + ].map(freezeEntry); + + function validateCatalog(entries) { + if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry'); + const logicalKeys = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('DataCatalog entry must be an object'); + } + if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) { + throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`); + } + logicalKeys.add(entry.logicalKey); + } + for (const entry of entries) { + if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) { + throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`); + } + if (!CATALOG_CLASSIFICATIONS.has(entry.classification) + || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION + || typeof entry.defaultValue !== 'function' + || typeof entry.normalize !== 'function' + || typeof entry.validate !== 'function' + || typeof entry.export !== 'boolean' + || !IMPORT_POLICIES.has(entry.import)) { + throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`); + } + try { + const defaultValue = entry.defaultValue(); + if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) { + throw new Error('invalid default'); + } + } catch (_) { + throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`); + } + } + return true; + } + + validateCatalog(definitions); + const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry])); + const DataCatalog = Object.freeze({ + version: V2_SCHEMA_VERSION, + list() { return definitions.slice(); }, + get(logicalKey) { + const entry = byKey.get(String(logicalKey || '')); + if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`); + return entry; + }, + has(logicalKey) { return byKey.has(String(logicalKey || '')); }, + validate: validateCatalog, + clone + }); + + Object.defineProperty(global, '__AppDataV2Catalog', { + value: DataCatalog, + enumerable: false, + configurable: true, + writable: false + }); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/data/v2/dataKernel.js b/js/data/v2/dataKernel.js new file mode 100644 index 00000000..dc04f861 --- /dev/null +++ b/js/data/v2/dataKernel.js @@ -0,0 +1,888 @@ +(function installDataKernel(global) { + 'use strict'; + + if (global.AppData) return; + + const catalog = global.__AppDataV2Catalog; + if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel'); + + const DATABASE_NAME = 'IELTSAtlasDataV2'; + // Version 2 uses a new schema, but initialization must still import the durable + // ExamSystemDB data owned by releases which predate AppData v2. + const DATABASE_VERSION = 2; + const DOCUMENT_STORE = 'documents'; + const SYSTEM_STORE = 'system'; + const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES)); + const OPERATION_JOURNAL_WINDOW = 500; + const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`; + const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000; + const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000; + const MAX_TIMER_DELAY_MS = 2147483647; + const LEGACY_DATABASE_NAME = 'ExamSystemDB'; + const LEGACY_STORE_NAME = 'keyValueStore'; + const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup'; + const LEGACY_EXTERNAL_STORE_NAME = 'handles'; + const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory'; + const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json'; + const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([ + 'practice_records', + 'vocab_user_config', + 'user_achievements' + ]); + + function clone(value) { return catalog.clone(value); } + function nowIso() { return new Date().toISOString(); } + function randomId(prefix) { + const random = global.crypto && typeof global.crypto.randomUUID === 'function' + ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `${prefix || 'op'}_${random}`; + } + + class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'AppDataError'; + this.code = code; + this.committed = false; + this.details = details; + } + } + function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); } + function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); } + + function normalizeTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback; + } + function scheduleTimeout(handler, delayMs) { + if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable'); + const handle = global.setTimeout.call(global, handler, delayMs); + if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle'); + return handle; + } + function cancelTimeout(handle) { + if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') { + try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ } + } + } + function withDeadline(handle, timeoutMs, description, resolve, reject) { + let settled = false; + let timer = null; + const settle = (callback, value) => { + if (settled) return; + settled = true; + cancelTimeout(timer); + callback(value); + }; + const expire = (error) => { + if (settled) return; + settled = true; + try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ } + reject(error); + }; + try { + timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, { + operation: description, timeoutMs, reason: 'timeout' + })), timeoutMs); + } catch (error) { + expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, { + operation: description, reason: 'watchdog-unavailable', cause: error && error.message + })); + } + return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } }; + } + + function canonicalizeJson(value, path = '$', ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path }); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') { + throw validation(`Non-JSON value at ${path}`, { path, type: typeof value }); + } + if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path }); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path }); + if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw validation(`Symbol-keyed property at ${path}`, { path }); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item, index) => { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path }); + return canonicalizeJson(item, `${path}[${index}]`, ancestors); + }); + } + const result = {}; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path }); + result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors); + } + return result; + } finally { ancestors.delete(value); } + } + function stableStringifyCanonical(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`; + } + function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); } + function checksum(value) { + const input = stableStringify(value); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + function legacyTimestamp(value) { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity; + if (Number.isFinite(Number(value))) return Number(value); + const parsed = Date.parse(value == null ? '' : String(value)); + return Number.isFinite(parsed) ? parsed : -Infinity; + } + function parseLegacyCandidate(value, outerTimestamp) { + let parsed = value; + let timestamp = legacyTimestamp(outerTimestamp); + const hasOuterTimestamp = timestamp !== -Infinity; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; } + } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') + && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) { + const innerTimestamp = legacyTimestamp(parsed.timestamp); + if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp; + parsed = parsed.data; + } else break; + } + return { value: clone(parsed), timestamp }; + } + function parseLegacyValue(value) { + const candidate = parseLegacyCandidate(value); + return candidate ? candidate.value : clone(value); + } + async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) { + const values = {}; + const candidates = {}; + let readComplete = true; + const consider = (alias, rawValue, timestamp, sourceRank) => { + const candidate = parseLegacyCandidate(rawValue, timestamp); + if (!candidate) return; + const previous = candidates[alias]; + if (!previous || candidate.timestamp > previous.timestamp + || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) { + candidates[alias] = Object.assign(candidate, { sourceRank }); + } + }; + if (indexedDBApi && typeof indexedDBApi.open === 'function') { + await new Promise((resolve) => { + let request; + let createdEmptyDatabase = false; + try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; } + request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); }; + request.onupgradeneeded = () => { + createdEmptyDatabase = true; + try { request.transaction.abort(); } catch (_) {} + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; } + const tx = db.transaction(LEGACY_STORE_NAME, 'readonly'); + const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys(); + const rows = tx.objectStore(LEGACY_STORE_NAME).getAll(); + tx.oncomplete = () => { + (keys.result || []).forEach((key, index) => { + const row = (rows.result || [])[index]; + // v1's keyValueStore persisted { key, value, timestamp } rows. + const validRow = row && typeof row === 'object' + && Object.prototype.hasOwnProperty.call(row, 'key') + && String(row.key) === String(key) + && Object.prototype.hasOwnProperty.call(row, 'value'); + if (!validRow) { + readComplete = false; + return; + } + consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0); + }); + db.close(); resolve(); + }; + tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); }; + }; + }); + } + for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) { + if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue; + for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) { + const key = fallbackStorage.key(index); + if (!key) continue; + const alias = key.startsWith('exam_system_') + ? key.slice('exam_system_'.length) + : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null); + if (!alias) continue; + try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ } + } + } + for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value; + Object.defineProperty(values, '__legacyReadComplete', { + value: readComplete, + enumerable: false, + configurable: false, + writable: false + }); + return values; + } + async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) { + if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null; + const directoryHandle = await new Promise((resolve) => { + let request; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value || null); + }; + try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; } + request.onerror = () => finish(null); + request.onupgradeneeded = () => { + try { request.transaction.abort(); } catch (_) {} + finish(null); + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) { + db.close(); finish(null); return; + } + const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly') + .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY); + get.onerror = () => { db.close(); finish(null); }; + get.onsuccess = () => { db.close(); finish(get.result); }; + }; + }); + if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null; + if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null; + const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false }); + const parsed = JSON.parse(await (await fileHandle.getFile()).text()); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined + ? parsed.data : parsed; + return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null; + } + function lookupEntry(logicalKey) { + if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey }); + return catalog.get(logicalKey); + } + function storeFor(logicalKey) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey }); + return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE; + } + function makeEnvelope(entry, data, options = {}) { + const state = options.state === 'cleared' ? 'cleared' : 'present'; + if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`); + let normalized = null; + if (state === 'present') { + try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) { + throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message }); + } + normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`); + if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey }); + } + const revision = options.revision === undefined ? 1 : Number(options.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`); + const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')), + updatedAt: options.updatedAt || nowIso(), state, data: normalized }; + payload.checksum = checksum(payload.data); + return Object.freeze(payload); + } + function validateEnvelope(entry, envelope) { + try { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) + || Number(envelope.schemaVersion) !== Number(entry.schemaVersion) + || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1 + || typeof envelope.operationId !== 'string' || !envelope.operationId + || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt + || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false; + const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`); + return (envelope.state !== 'cleared' || data === null) + && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data); + } catch (_) { return false; } + } + function operationId(value) { + if (value === undefined || value === null || value === '') return randomId('mutation'); + if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string'); + return value; + } + function expectedRevision(value, label) { + if (value === undefined || value === null) return null; + const revision = Number(value); + if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`); + return revision; + } + function compactJournal(journal) { + const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence)); + for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]]; + } + function readJournal(row) { + const envelope = row && row.envelope; + return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? clone(envelope.data) : {}; + } + function journalResult(journal, spec) { + const existing = journal[spec.operationId]; + if (!existing) return null; + if (existing.fingerprint !== spec.fingerprint || !existing.receipt) { + throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId }); + } + return clone(existing.receipt); + } + function writeJournal(journal, spec, receipt) { + const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1; + journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() }; + compactJournal(journal); + return journal; + } + function putJournal(tx, currentRow, journal, spec, receipt) { + const current = currentRow && currentRow.envelope; + const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), { + revision: current ? Number(current.revision) + 1 : 1, + operationId: spec.operationId, + normalized: true + }); + tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) }); + } + + class IndexedDBDriver { + constructor(indexedDBApi, options) { + this.indexedDB = indexedDBApi; + this.db = null; + this.mutationTimeoutMs = options.mutationTimeoutMs; + this.requestTimeoutMs = options.requestTimeoutMs; + } + async initialize() { + if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable'); + this.db = await new Promise((resolve, reject) => { + const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + let abandoned = false; + let settle; + request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion < 2) { + for (const name of ['authoritative', 'derived']) { + if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name); + } + } + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' }); + } + }; + settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject); + request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB')); + request.onblocked = () => { + abandoned = true; + settle.reject(new Error('IndexedDB upgrade blocked')); + }; + }); + this.db.onversionchange = () => this.close(); + return this; + } + close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} } + _open() { if (!this.db) throw new Error('IndexedDB connection closed'); } + _transaction(stores, mode, description, work, mutation = false) { + this._open(); + return new Promise((resolve, reject) => { + let failure = null; + let value; + let tx; + try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; } + const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject); + tx.oncomplete = () => settle.resolve(clone(value)); + tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); }; + tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`)); + const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} }; + try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); } + }); + } + readEnvelope(logicalKey) { + const store = storeFor(logicalKey); + return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(logicalKey); + request.onsuccess = () => done(request.result ? request.result.envelope : null); + request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`)); + }); + } + readEntity(store, recordId) { + return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(recordId); + request.onsuccess = () => done(request.result || null); + request.onerror = () => fail(request.error || new Error('Entity read failed')); + }); + } + readPracticeSnapshot(recordIds = null, options = {}) { + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES.slice(); + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean)); + return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => { + const result = Object.fromEntries(stores.map((store) => [store, []])); + let remaining = stores.length; + const finishStore = (store, rows) => { + result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || ''))); + remaining -= 1; + if (!remaining) done(result); + }; + for (const store of stores) { + const objectStore = tx.objectStore(store); + const request = requested && requested.size === 1 + ? objectStore.get(Array.from(requested)[0]) + : objectStore.getAll(); + request.onsuccess = () => { + const rows = requested && requested.size === 1 + ? (request.result ? [request.result] : []) + : request.result; + finishStore(store, rows); + }; + request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`)); + } + }); + } + listEntities(store) { + return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => { + const request = tx.objectStore(store).getAll(); + request.onsuccess = () => done(request.result || []); + request.onerror = () => fail(request.error || new Error('Entity list failed')); + }); + } + atomic(spec) { + return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => { + const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal'); + journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed')); + journalRequest.onsuccess = () => { + try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); } + }; + }, true); + } + exportSnapshot(envelopeKeys) { + return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => { + const result = { envelopes: {}, entities: {} }; + let remaining = STORE_NAMES.length; + for (const store of STORE_NAMES) { + const request = tx.objectStore(store).getAll(); + request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`)); + request.onsuccess = () => { + if (store === DOCUMENT_STORE || store === SYSTEM_STORE) { + for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope; + } else result.entities[store] = request.result || []; + remaining -= 1; + if (!remaining) done(result); + }; + } + }); + } + } + + function entityStore(store) { + const value = String(store || ''); + if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value }); + return value; + } + function validateEntityRow(store, row) { + if (!row || typeof row !== 'object' || Array.isArray(row) + || typeof row.recordId !== 'string' || !row.recordId + || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1 + || typeof row.operationId !== 'string' || !row.operationId + || typeof row.updatedAt !== 'string' || !row.updatedAt) { + throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null }); + } + const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`); + if (row.checksum !== checksum(data)) { + throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId }); + } + return row; + } + function normalizeEntityOperation(operation, index) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`); + const type = String(operation.type || ''); + const store = entityStore(operation.store); + if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`); + const recordId = type === 'clear' ? null : String(operation.recordId || ''); + if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`); + const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null; + return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) }; + } + function receiptFor(operationIdValue, revisions, warnings, pending) { + const receipt = { committed: true, revisions, operationId: operationIdValue, + derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() }; + const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]]; + return receipt; + } + + class DataKernel { + constructor(options = {}) { + this.driver = null; + this.backend = null; + this.state = 'created'; + this.failure = null; + this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB; + this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS); + this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS); + this.committedListeners = new Set(); + this.commitChannel = null; + this.instanceId = randomId('kernel'); + this.ready = null; + } + _initializeCommitChannel() { + if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return; + try { + const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME); + channel.onmessage = (message) => { + const data = message && message.data; + if (!data || data.sourceInstanceId === this.instanceId + || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return; + this._dispatchCommitted({ + operationId: data.operationId, + targets: clone(data.targets), + receipt: data.receipt ? clone(data.receipt) : null, + remote: true + }); + }; + this.commitChannel = channel; + } catch (_) { + this.commitChannel = null; + } + } + _closeCommitChannel() { + const channel = this.commitChannel; + this.commitChannel = null; + try { if (channel) channel.close(); } catch (_) {} + } + initialize() { + if (this.ready) return this.ready; + this.state = 'initializing'; + this.ready = new IndexedDBDriver(this.indexedDB, { + mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs + }).initialize().then((driver) => { + this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this; + }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null; + throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); }); + return this.ready; + } + close() { + if (this.driver) this.driver.close(); + this.driver = null; this.backend = null; + this._closeCommitChannel(); + if (this.state !== 'failed') this.state = 'closed'; + } + _assertReady() { + if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message }); + if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized'); + } + _latch(error) { + if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message }); + this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel(); + return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message }); + } + onCommitted(listener) { + if (typeof listener !== 'function') throw validation('Committed listener must be a function'); + this.committedListeners.add(listener); return () => this.committedListeners.delete(listener); + } + _dispatchCommitted(event) { + if (!event || !this.committedListeners.size) return; + const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback); + schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} })); + } + _notifyCommitted(targets, receipt) { + if (!targets.length) return; + const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false }; + this._dispatchCommitted(event); + if (this.commitChannel) { + try { + this.commitChannel.postMessage({ + sourceInstanceId: this.instanceId, + operationId: event.operationId, + targets: event.targets, + receipt: event.receipt + }); + } catch (_) { /* cross-realm notification is best effort */ } + } + } + async getEnvelope(logicalKey) { + this._assertReady(); const entry = lookupEntry(logicalKey); + try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async read(logicalKey, options = {}) { + const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey); + const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data; + return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data); + } + _documentSpec(changes, options) { + if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes'); + const opId = operationId(options.operationId); const seen = new Set(); + const prepared = changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`); + const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey); + if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel'); + if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey); + const state = change.state === 'cleared' ? 'cleared' : 'present'; + if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`); + if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`); + let data = null; + if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); } + return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) }; + }); + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings }); + return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) }; + } + async mutate(changes, options = {}) { + this._assertReady(); const spec = this._documentSpec(changes, options); + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) })); + let remaining = reads.length; + const finish = () => { + const revisions = {}; + for (const item of reads) { + const current = item.request.result ? item.request.result.envelope : null; + if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey }); + const revision = current ? Number(current.revision) : 0; + if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision }); + const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true }); + tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision; + } + const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []); + putJournal(tx, journalRow, journal, spec, receipt); + done(receipt); + }; + if (!remaining) { finish(); return; } + for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification })); + this._notifyCommitted(targets, receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); } + async readEntity(store, recordId, options = {}) { + this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId'); + try { + const row = await this.driver.readEntity(store, id); + if (!row) return null; + validateEntityRow(store, row); + return options.withMeta ? clone(row) : clone(row.data); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async readPracticeSnapshot(recordIds = null, options = {}) { + this._assertReady(); + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + try { + const snapshot = await this.driver.readPracticeSnapshot(ids, options); + const result = {}; + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES; + for (const store of stores) { + const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : []) + .filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + result[store] = options.withMeta + ? clone(validRows) + : validRows.map((row) => clone(row.data)); + } + return result; + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async listEntities(store, options = {}) { + this._assertReady(); store = entityStore(store); + if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId'); + try { + const rows = await this.driver.listEntities(store); + const validRows = rows.filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data)); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async mutateEntities(operations, options = {}) { + this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations'); + const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set(); + for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); } + for (const store of ENTITY_STORES) { + const scoped = items.filter((item) => item.store === store); + if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) { + throw validation(`Entity clear cannot be combined with other operations for ${store}`); + } + } + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length; + const finish = () => { const revisions = {}; + for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0; + if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`); + const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; } + } + for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; } + const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); }; + if (!remaining) { try { finish(); } catch (error) { fail(error); } return; } + for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async exportSnapshot(options = {}) { + this._assertReady(); + try { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null; + const shouldExport = (logicalKey) => { + if (!catalog.has(logicalKey)) return false; + const entry = lookupEntry(logicalKey); + if (selected && !selected.has(logicalKey)) return false; + if (entry.export === true) return true; + return options.includeSystem === true && entry.classification === 'system'; + }; + const data = await this.driver.exportSnapshot(shouldExport); + // Full/partial snapshots must be dense for their declared catalog + // range. An absent physical row means the catalog default, not an + // instruction that future importers should guess about. + for (const entry of catalog.list()) { + if (!shouldExport(entry.logicalKey) + || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue; + data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, { + state: 'cleared', + operationId: 'snapshot-default' + }); + } + if (Array.isArray(options.entityStores)) { + const selectedStores = new Set(options.entityStores.map(entityStore)); + for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store]; + } + const payload = { envelopes: data.envelopes, entities: data.entities }; + return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) }; + } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async installSnapshot(snapshot, options = {}) { + this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} }; + const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities'); + if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid'); + if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch'); + const changes = []; + for (const [logicalKey, envelope] of Object.entries(envelopes)) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue; + if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`); + changes.push({ logicalKey, entry, envelope }); + } + const entityRows = {}; + for (const store of ENTITY_STORES) { + if (!Object.prototype.hasOwnProperty.call(entities, store)) continue; + const rows = entities[store]; + if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`); + const ids = new Set(); + entityRows[store] = rows.map((row) => { + if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`); + const recordId = String(row.recordId); + if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`); + ids.add(recordId); + const data = canonicalizeJson(row.data); + if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`); + const revision = row.revision === undefined ? 1 : Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`); + return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) }; + }); + } + if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data'); + const resetJournal = options.resetJournal === true; + const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object' + ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken') + : null; + const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {}; + const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {}; + const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({ + kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey) + })).concat(Object.entries(entityChecks).map(([store, expected]) => ({ + kind: 'entities', store, expected, request: tx.objectStore(store).getAll() + }))); + const finish = () => { + for (const read of reads) { + if (read.kind === 'document') { + const current = read.request.result ? read.request.result.envelope : null; + const actualRevision = current ? Number(current.revision) : 0; + const expectedRevision = Number(read.expected) || 0; + if (actualRevision !== expectedRevision) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision }); + } + } else { + const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0])); + const expected = read.expected && typeof read.expected === 'object' ? read.expected : {}; + const ids = new Set(Object.keys(actual).concat(Object.keys(expected))); + for (const recordId of ids) { + const current = actual[recordId] || 0; + const wanted = Number(expected[recordId]) || 0; + if (current !== wanted) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId }); + } + } + } + } + const revisions = {}; + for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); } + for (const [store, rows] of Object.entries(entityRows)) { + tx.objectStore(store).clear(); + for (const row of rows) tx.objectStore(store).put(row); + } + const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt); + }; + if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; } + let remaining = reads.length; + for (const read of reads) { + read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed')); + read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; + } + } })); + const targets = changes + .filter((item) => item.entry.owner !== 'backups') + .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification })) + .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' }))); + this._notifyCommitted(targets, receipt); + return receipt; + } + catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); } + } + + Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false }); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/patches/runtime-fixes.js b/js/patches/runtime-fixes.js deleted file mode 100644 index 523c07ea..00000000 --- a/js/patches/runtime-fixes.js +++ /dev/null @@ -1,109 +0,0 @@ -// Runtime fixes to smooth async storage + recovery under file:// -(function () { - 'use strict'; - - function ensureCompatPatch(global) { - if (!global || (global.CompatPatch && typeof global.CompatPatch.register === 'function')) { - return global && global.CompatPatch ? global.CompatPatch : null; - } - var patches = []; - var register = function register(name, metadata) { - if (!name) { - return null; - } - var patch = Object.assign({ - name: String(name), - owner: 'legacy', - reason: '', - removeAfter: '' - }, metadata || {}); - patches.push(patch); - return patch; - }; - var list = function list() { - return patches.slice(); - }; - global.CompatPatch = Object.assign({}, global.CompatPatch || {}, { - register: register, - list: list - }); - return global.CompatPatch; - } - - ensureCompatPatch(window); - - if (window.CompatPatch && typeof window.CompatPatch.register === 'function') { - window.CompatPatch.register('practice-recorder-temp-recovery-async', { - owner: 'practice', - reason: 'file protocol compatible recovery for legacy temporary practice records', - removeAfter: 'after PracticeRecorder recovery is canonical' - }); - } - - try { - // Patch PracticeRecorder.recoverTemporaryRecords to a robust async version - const patchPracticeRecorder = () => { - const PR = window.PracticeRecorder; - if (!PR || !PR.prototype) return false; - - const original = PR.prototype.recoverTemporaryRecords; - PR.prototype.recoverTemporaryRecords = async function () { - try { - const raw = (window.storage && storage.get) - ? await storage.get('temp_practice_records', []) - : []; - const tempRecords = Array.isArray(raw) ? raw : []; - - if (tempRecords.length === 0) { - console.log('[PracticeRecorder] 没有需要恢复的临时记录'); - return; - } - - console.log(`[PracticeRecorder] 发现 ${tempRecords.length} 条临时记录,开始恢复...`); - - let recoveredCount = 0; - const failed = []; - - for (const tempRecord of tempRecords) { - try { - const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord || {}; - const sanitized = (this && typeof this.sanitizeRecoveredRecord === 'function') - ? this.sanitizeRecoveredRecord(cleanRecord) - : cleanRecord; - if (!sanitized || !sanitized.examId) { - console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord && cleanRecord.id); - continue; - } - if (this && typeof this.savePracticeRecord === 'function') { - await this.savePracticeRecord(sanitized); - } - recoveredCount++; - console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized && sanitized.id}`); - } catch (e) { - console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord && tempRecord.id}`, e); - failed.push(tempRecord); - } - } - - if (failed.length === 0) { - if (window.storage && storage.remove) await storage.remove('temp_practice_records'); - console.log(`[PracticeRecorder] 所有 ${recoveredCount} 条临时记录恢复成功`); - } else { - if (window.storage && storage.set) await storage.set('temp_practice_records', failed); - console.log(`[PracticeRecorder] 恢复了 ${recoveredCount} 条记录,${failed.length} 条失败`); - } - } catch (error) { - console.error('[PracticeRecorder] 恢复临时记录时出错:', error); - } - }; - - console.log('[RuntimeFixes] PracticeRecorder.recoverTemporaryRecords 已替换为异步实现'); - return true; - }; - - const tryPatch = () => { - if (!patchPracticeRecorder()) setTimeout(tryPatch, 100); - }; - tryPatch(); - } catch (_) {} -})(); diff --git a/js/presentation/developerTeamModal.js b/js/presentation/developerTeamModal.js deleted file mode 100644 index fef71d48..00000000 --- a/js/presentation/developerTeamModal.js +++ /dev/null @@ -1,58 +0,0 @@ -(function initDeveloperTeamModal(global) { - 'use strict'; - - function getModal() { - return document.getElementById('developer-modal'); - } - - if (typeof global.showDeveloperTeam !== 'function') { - global.showDeveloperTeam = function showDeveloperTeam() { - var modal = getModal(); - if (modal) { - modal.classList.add('show'); - } - }; - } - - if (typeof global.hideDeveloperTeam !== 'function') { - global.hideDeveloperTeam = function hideDeveloperTeam() { - var modal = getModal(); - if (modal) { - modal.classList.remove('show'); - } - }; - } - - function setupDismissHandlers() { - var modal = getModal(); - if (!modal || modal.dataset.dismissBound === '1') { - return; - } - - modal.addEventListener('click', function onBackdropClick(event) { - if (event.target === modal) { - global.hideDeveloperTeam(); - } - }); - - modal.dataset.dismissBound = '1'; - } - - function handleEscape(event) { - if (event.key !== 'Escape') { - return; - } - var modal = getModal(); - if (modal && modal.classList.contains('show')) { - global.hideDeveloperTeam(); - } - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', setupDismissHandlers); - } else { - setupDismissHandlers(); - } - - document.addEventListener('keydown', handleEscape); -})(typeof window !== 'undefined' ? window : this); diff --git a/js/utils/dataBackupManager.js b/js/utils/dataBackupManager.js deleted file mode 100644 index 33eddd21..00000000 --- a/js/utils/dataBackupManager.js +++ /dev/null @@ -1,900 +0,0 @@ -/** - * Data backup and recovery manager. - * Provides export/import/cleanup functionality for the shared storage layer. - */ -class DataBackupManager { - constructor() { - this.storageKeys = { - backupSettings: 'backup_settings', - exportHistory: 'export_history', - importHistory: 'import_history', - manualBackups: 'manual_backups' - }; - - this.supportedFormats = ['json', 'csv']; - this.maxBackupHistory = 20; - this.maxExportHistory = 50; - - this.initialize(); - } - - sanitizeExamTitle(title) { - if (!title) return ''; - const str = String(title).trim(); - if (!str) return ''; - const pattern = /ielts\s+listening\s+practice\s*-\s*part\s*\d+\s*[:\-]?\s*(.+)$/i; - const match = str.match(pattern); - if (match && match[1]) { - return match[1].trim(); - } - if (str.includes(' - ')) { - const segments = str.split(' - ').map((s) => s.trim()).filter(Boolean); - if (segments.length > 1) { - return segments[segments.length - 1]; - } - } - return str; - } - - sanitizeRecord(record) { - if (!record || typeof record !== 'object') { - return record; - } - const clone = { ...record }; - const metadata = (clone.metadata && typeof clone.metadata === 'object') ? { ...clone.metadata } : {}; - const baseTitle = metadata.examTitle || metadata.title || clone.title || clone.examTitle; - const cleanedTitle = this.sanitizeExamTitle(baseTitle); - if (cleanedTitle) { - metadata.examTitle = cleanedTitle; - metadata.title = metadata.title || cleanedTitle; - clone.title = cleanedTitle; - if (!clone.examTitle) { - clone.examTitle = cleanedTitle; - } - clone.metadata = metadata; - } - return clone; - } - - async initialize() { - try { - await this.initializeSettings(); - } catch (error) { - console.error('[DataBackupManager] failed to initialize settings', error); - } - - this.setupPeriodicCleanup(); - } - - async initializeSettings() { - const defaults = { - autoBackup: true, - backupInterval: 24, - maxBackups: 10, - compressionEnabled: false, - encryptionEnabled: false, - lastAutoBackup: null - }; - - try { - const stored = await storage.get(this.storageKeys.backupSettings, defaults); - await storage.set(this.storageKeys.backupSettings, { ...defaults, ...stored }); - } catch (error) { - console.error('[DataBackupManager] unable to persist settings', error); - } - } - - async listPracticeRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return await window.PracticeRecordAPI.list(); - } - - throw new Error('统一练习记录存储未就绪'); - } - - async replacePracticeRecords(records, options = {}) { - const normalizedRecords = Array.isArray(records) ? records : []; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') { - await window.PracticeRecordAPI.replace(normalizedRecords, options); - return true; - } - - throw new Error('统一练习记录存储未就绪'); - } - - async restorePracticeRecords(records, stats = null) { - const normalizedRecords = Array.isArray(records) ? records : []; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') { - return await window.PracticeRecordAPI.restoreRecords(normalizedRecords, { - stats: this.isPlainObject(stats) ? stats : null, - updateStats: true - }); - } - - throw new Error('统一练习记录恢复 API 未就绪'); - } - - async readUserStats() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - return await window.PracticeRecordAPI.readStats(); - } - - throw new Error('统一练习统计 API 未就绪'); - } - - async mergeUserStats(stats, mergeMode = 'merge') { - if (!this.isPlainObject(stats)) { - return await this.readUserStats(); - } - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeStats === 'function') { - return await window.PracticeRecordAPI.mergeStats(stats, { mergeMode }); - } - - throw new Error('统一练习统计 API 未就绪'); - } - - async resetUserStats(stats = null) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.resetStats === 'function') { - return await window.PracticeRecordAPI.resetStats(stats); - } - - throw new Error('统一练习统计 API 未就绪'); - } - - async createBackup(backupName = null, type = 'manual') { - if (window.BackupAPI && typeof window.BackupAPI.create === 'function') { - return await window.BackupAPI.create({ - id: backupName || undefined, - type - }); - } - - // Fallback when BackupAPI not loaded yet (early boot / isolated tests) - const practiceRecords = await this.listPracticeRecords(); - const userStats = await this.readUserStats(); - const examIndex = await storage.get('exam_index', []); - const backup = { - id: backupName || `backup_${Date.now()}`, - timestamp: new Date().toISOString(), - type, - data: { - practice_records: practiceRecords, - practiceRecords, - user_stats: userStats, - userStats, - exam_index: examIndex, - examIndex - } - }; - - const backups = await storage.get(this.storageKeys.manualBackups, []); - backups.unshift(backup); - while (backups.length > this.maxBackupHistory) { - backups.pop(); - } - await storage.set(this.storageKeys.manualBackups, backups); - return backup.id; - } - - async exportPracticeRecords(options = {}) { - const { - format = 'json', - includeStats = true, - includeBackups = false, - dateRange = null, - categories = null, - compression = false - } = options; - - const normalizedFormat = String(format).toLowerCase(); - if (!this.supportedFormats.includes(normalizedFormat)) { - throw new Error(`Unsupported export format: ${format}`); - } - - let practiceRecords = await this.listPracticeRecords(); - practiceRecords = Array.isArray(practiceRecords) ? practiceRecords : []; - - if (dateRange) { - practiceRecords = this.filterByDateRange(practiceRecords, dateRange); - } - - if (Array.isArray(categories) && categories.length) { - practiceRecords = practiceRecords.filter(record => categories.includes(record?.metadata?.category)); - } - - const exportPayload = { - exportInfo: { - timestamp: new Date().toISOString(), - version: '0.6.2-fix', - format: normalizedFormat, - recordCount: practiceRecords.length, - options: { format, includeStats, includeBackups, dateRange, categories } - }, - practiceRecords - }; - - if (includeStats) { - exportPayload.userStats = await this.readUserStats(); - } - - if (includeBackups) { - try { - // 统一经 BackupAPI 读全量列表;不再经 scoreStorage 的类型过滤旁路 - if (window.BackupAPI && typeof window.BackupAPI.list === 'function') { - exportPayload.backups = await window.BackupAPI.list(); - } else { - exportPayload.backups = await storage.get(this.storageKeys.manualBackups, []); - } - if (!Array.isArray(exportPayload.backups)) { - exportPayload.backups = []; - } - } catch (error) { - console.warn('[DataBackupManager] failed to include backups in export', error); - exportPayload.backups = []; - } - } - - await this.recordExportHistory(exportPayload.exportInfo); - - switch (normalizedFormat) { - case 'json': - return this.exportAsJSON(exportPayload, compression); - case 'csv': - return this.exportAsCSV(exportPayload); - default: - throw new Error(`Format ${format} not implemented`); - } - } - - exportAsJSON(data, compressionEnabled = false) { - const raw = JSON.stringify(data, null, 2); - const payload = compressionEnabled ? this.compressData(raw) : raw; - - return { - data: payload, - filename: `practice_records_${this.getTimestamp()}.json`, - mimeType: 'application/json', - size: payload.length, - compressed: compressionEnabled - }; - } - - exportAsCSV(data) { - const records = Array.isArray(data.practiceRecords) ? data.practiceRecords : []; - const headers = [ - 'record_id', - 'exam_id', - 'title', - 'status', - 'score', - 'accuracy', - 'duration_seconds', - 'start_time', - 'end_time', - 'category', - 'frequency', - 'created_at' - ]; - - const rows = records.map(record => { - const metadata = record?.metadata || {}; - return [ - record?.id ?? '', - record?.examId ?? '', - record?.title ?? '', - record?.status ?? '', - record?.score ?? '', - record?.accuracy ?? '', - record?.duration ?? '', - record?.startTime ?? '', - record?.endTime ?? '', - metadata.category ?? '', - metadata.frequency ?? '', - record?.createdAt ?? '' - ]; - }); - - const csvContent = [headers, ...rows] - .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')) - .join('\n'); - - return { - data: csvContent, - filename: `practice_records_${this.getTimestamp()}.csv`, - mimeType: 'text/csv', - size: csvContent.length - }; - } - /** - * Legacy-friendly wrapper. - */ - async importPracticeRecords(source, options = {}) { - return this.importPracticeData(source, options); - } - - async importPracticeData(source, options = {}) { - console.log('[DataBackupManager] importPracticeData called, source type:', typeof source, 'length:', Array.isArray(source) ? source.length : source.practiceRecords?.length); - const { - mergeMode = 'merge', - createBackup = true, - preserveIds = true - } = options; - - let payload; - try { - payload = await this.parseImportSource(source, { allowFetch: true }); - } catch (error) { - throw new Error(`Failed to read import source: ${error.message}`); - } - - const normalized = this.normalizeImportPayload(payload, { preserveIds }); - console.log('[DataBackupManager] Normalized records:', normalized.practiceRecords.length); - - let practiceRecords = Array.isArray(normalized.practiceRecords) ? normalized.practiceRecords : []; - - if (!practiceRecords.length) { - throw new Error('Import file does not contain any practice records.'); - } - - practiceRecords = practiceRecords.map((r) => this.sanitizeRecord(r)); - normalized.practiceRecords = practiceRecords; - console.log('[DataBackupManager] After sanitize, records:', normalized.practiceRecords.length); - - let backupId = null; - if (createBackup) { - backupId = await this.createPreImportBackup(); - console.log('[DataBackupManager] Pre-import backup created:', backupId); - } - - let mergeResult; - try { - // 若备份同时携带 user_stats,导入 records 时禁止并发 recalculateStats, - // 否则会与后续 mergeUserStats 竞态,覆盖备份中的 practiceDays/streakDays 等字段。 - const hasImportedStats = Boolean(normalized.userStats); - mergeResult = await this.mergePracticeRecords( - normalized.practiceRecords, - mergeMode, - { updateStats: !hasImportedStats } - ); - console.log('[DataBackupManager] Practice records imported through PracticeRecordAPI'); - - if (normalized.userStats) { - await this.mergeUserStats(normalized.userStats, mergeMode); - } - } catch (error) { - if (backupId) { - try { - await this.restoreBackup(backupId); - } catch (restoreError) { - console.error('[DataBackupManager] failed to restore backup after import error', restoreError); - } - } - - await this.recordImportHistory({ - timestamp: new Date().toISOString(), - mergeMode, - backupId, - success: false, - error: error.message - }); - throw error; - } - - await this.recordImportHistory({ - timestamp: new Date().toISOString(), - recordCount: mergeResult.importedCount, - mergeMode, - backupId, - sources: normalized.sources, - success: true - }); - - return { - success: true, - ...mergeResult, - backupId, - statsImported: Boolean(normalized.userStats), - sources: normalized.sources - }; - } - - async parseImportSource(source, { allowFetch = false } = {}) { - if (source === undefined || source === null) { - throw new Error('Import source is empty.'); - } - - if (typeof File !== 'undefined' && source instanceof File) { - return this.parseImportSource(await source.text(), { allowFetch }); - } - - if (typeof Blob !== 'undefined' && source instanceof Blob) { - return this.parseImportSource(await source.text(), { allowFetch }); - } - - if (typeof source === 'string') { - const trimmed = source.trim(); - if (!trimmed) { - throw new Error('Import source string is empty.'); - } - - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - try { - return JSON.parse(trimmed); - } catch (error) { - throw new Error('Import string is not valid JSON.'); - } - } - - if (!allowFetch) { - throw new Error('Import string is neither JSON nor a fetchable path.'); - } - - const response = await fetch(trimmed); - if (!response.ok) { - throw new Error(`Failed to fetch import file: ${response.status}`); - } - return await response.json(); - } - - if (Array.isArray(source) || this.isPlainObject(source)) { - return source; - } - - throw new Error('Unsupported import source type.'); - } - - normalizeImportPayload(payload, { preserveIds = true } = {}) { - if (payload === undefined || payload === null) { - throw new Error('Import data is empty.'); - } - - const practiceRecords = []; - const sources = []; - let userStats = null; - - if (this.isPlainObject(payload)) { - const directStats = payload.user_stats - ?? payload.userStats - ?? payload.stats - ?? payload.data?.user_stats - ?? payload.data?.userStats - ?? payload.data?.stats; - if (this.isPlainObject(directStats)) { - userStats = this.prepareUserStats(directStats); - } - } - - this.extractRecordSources(payload).forEach(({ records, source }) => { - const normalizedRecords = records - .map((record, index) => this.normalizeRecord(record, { - preserveIds, - fallbackIdPrefix: source || 'record', - index - })) - .filter(Boolean); - - if (normalizedRecords.length) { - practiceRecords.push(...normalizedRecords); - sources.push({ path: source || '(root array)', count: normalizedRecords.length }); - } - }); - - // Dual-schema payloads and multi-path recovery can surface the same id twice; - // keep first occurrence so replace-mode import does not invent duplicates. - const seenIds = new Set(); - const dedupedPracticeRecords = []; - practiceRecords.forEach((record) => { - if (!record || typeof record !== 'object') { - return; - } - const id = record.id != null ? String(record.id) : null; - if (id) { - if (seenIds.has(id)) { - return; - } - seenIds.add(id); - } - dedupedPracticeRecords.push(record); - }); - - return { - practiceRecords: dedupedPracticeRecords, - userStats, - sources - }; - } - - extractRecordSources(payload) { - const sources = []; - const add = (source, records) => { - if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) { - sources.push({ source, records }); - } - }; - // App backups write dual aliases (practice_records + practiceRecords) for the same list. - // Prefer the first non-empty array so replace-mode import does not double-append. - const addPreferred = (candidates) => { - for (const { source, records } of candidates) { - if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) { - add(source, records); - return true; - } - } - return false; - }; - - if (Array.isArray(payload)) { - add('(root array)', payload); - return sources; - } - if (!this.isPlainObject(payload)) { - return sources; - } - - addPreferred([ - { source: 'practice_records', records: payload.practice_records }, - { source: 'practiceRecords', records: payload.practiceRecords } - ]); - - const data = this.isPlainObject(payload.data) ? payload.data : {}; - const dataArrayPicked = addPreferred([ - { source: 'data.practice_records', records: data.practice_records }, - { source: 'data.practiceRecords', records: data.practiceRecords } - ]); - // Envelope form only when the preferred alias was not already a plain array source. - if (!dataArrayPicked && this.isPlainObject(data.practice_records)) { - add('data.practice_records.data', data.practice_records.data); - } else if (!dataArrayPicked && this.isPlainObject(data.practiceRecords)) { - add('data.practiceRecords.data', data.practiceRecords.data); - } - if (this.isPlainObject(data.exam_system_practice_records)) { - add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); - } - if (this.isPlainObject(payload.exam_system_practice_records)) { - add('exam_system_practice_records.data', payload.exam_system_practice_records.data); - } - - return sources; - } - - async mergePracticeRecords(newRecords, mergeMode = 'merge', options = {}) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeRecords === 'function') { - return await window.PracticeRecordAPI.mergeRecords( - Array.isArray(newRecords) ? newRecords : [], - { - mergeMode, - updateStats: options.updateStats !== false - } - ); - } - - throw new Error('统一练习记录导入 API 未就绪'); - } - - prepareUserStats(candidate) { - if (!this.isPlainObject(candidate)) { - return null; - } - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.prepareStats === 'function') { - return window.PracticeRecordAPI.prepareStats(candidate); - } - throw new Error('统一练习统计 API 未就绪'); - } - - normalizeRecord(record, options = {}) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.normalizeRecord === 'function') { - return window.PracticeRecordAPI.normalizeRecord(record, options); - } - throw new Error('统一练习记录标准化 API 未就绪'); - } - normalizeDateValue(value) { - if (!value) { - return null; - } - - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString(); - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - if (/^\d+$/.test(trimmed)) { - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000; - return new Date(milliseconds).toISOString(); - } - } - - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - - return null; - } - - getRecordTimestamp(record) { - if (!record) { - return 0; - } - - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.timestamp, - record.date - ]; - - for (const candidate of candidates) { - const iso = this.normalizeDateValue(candidate); - if (iso) { - const time = new Date(iso).getTime(); - if (Number.isFinite(time)) { - return time; - } - } - } - - return 0; - } - - filterByDateRange(records, dateRange) { - const { startDate, endDate } = dateRange; - return (records || []).filter(record => { - const value = this.normalizeDateValue(record?.startTime ?? record?.createdAt ?? record?.timestamp); - if (!value) { - return false; - } - - const recordDate = new Date(value); - if (startDate && recordDate < new Date(startDate)) { - return false; - } - if (endDate && recordDate > new Date(endDate)) { - return false; - } - return true; - }); - } - - compressData(data) { - try { - if (window.pako && typeof window.pako.gzip === 'function') { - return window.pako.gzip(data, { to: 'string' }); - } - } catch (error) { - console.warn('[DataBackupManager] compression failed', error); - } - return data; - } - async createPreImportBackup() { - try { - // 与 createBackup 共用 unshift + pop 裁剪,避免 push+shift 误删最新用户备份 - return await this.createBackup(`pre_import_${Date.now()}`, 'pre_import'); - } catch (error) { - console.error('[DataBackupManager] failed to create backup', error); - return null; - } - } - - async restoreBackup(backupId) { - if (!backupId) { - throw new Error('Invalid backup id.'); - } - - try { - if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') { - const result = await window.BackupAPI.restore(backupId); - return result.backup; - } - - const backups = await storage.get(this.storageKeys.manualBackups, []); - const backup = backups.find(item => item.id === backupId); - if (!backup) { - throw new Error(`Backup ${backupId} not found.`); - } - - const data = backup.data || {}; - const records = Array.isArray(data.practice_records) - ? data.practice_records - : (Array.isArray(data.practiceRecords) ? data.practiceRecords : []); - const stats = this.isPlainObject(data.user_stats) - ? data.user_stats - : (this.isPlainObject(data.userStats) ? data.userStats : null); - - await this.restorePracticeRecords(records, stats); - - const examIndex = Array.isArray(data.exam_index) - ? data.exam_index - : (Array.isArray(data.examIndex) ? data.examIndex : null); - if (examIndex) { - await storage.set('exam_index', examIndex); - } - - return backup; - } catch (error) { - console.error('[DataBackupManager] backup restore failed', error); - throw error; - } - } - - async clearData(options = {}) { - const { - clearPracticeRecords = false, - clearUserStats = false, - clearBackups = false, - clearSettings = false, - createBackup = true - } = options; - - let backupId = null; - if (createBackup) { - backupId = await this.createPreImportBackup(); - } - - const clearedItems = []; - - if (clearPracticeRecords) { - await this.replacePracticeRecords([], { updateStats: !clearUserStats }); - clearedItems.push('practice_records'); - if (!clearUserStats) { - clearedItems.push('user_stats'); - } - } - - if (clearUserStats) { - await this.resetUserStats(); - clearedItems.push('user_stats'); - } - - if (clearBackups) { - if (window.BackupAPI && typeof window.BackupAPI.clear === 'function') { - await window.BackupAPI.clear(); - } else { - await storage.set(this.storageKeys.manualBackups, []); - } - if (typeof storage.remove === 'function') { - await storage.remove('backup_data'); - } - clearedItems.push('backups'); - } - - if (clearSettings) { - await storage.remove('settings'); - await storage.remove(this.storageKeys.backupSettings); - clearedItems.push('settings'); - } - - return { - success: true, - clearedItems, - backupId - }; - } - - async recordExportHistory(info) { - const history = await storage.get(this.storageKeys.exportHistory, []); - history.push({ ...info, id: `export_${Date.now()}` }); - while (history.length > this.maxExportHistory) { - history.shift(); - } - await storage.set(this.storageKeys.exportHistory, history); - } - - async recordImportHistory(info) { - const history = await storage.get(this.storageKeys.importHistory, []); - history.push({ ...info, id: `import_${Date.now()}` }); - while (history.length > this.maxExportHistory) { - history.shift(); - } - await storage.set(this.storageKeys.importHistory, history); - } - - async getExportHistory() { - const history = await storage.get(this.storageKeys.exportHistory, []); - return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } - - async getImportHistory() { - const history = await storage.get(this.storageKeys.importHistory, []); - return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } - async getDataStats() { - try { - const practiceRecords = await this.listPracticeRecords(); - const userStats = await this.readUserStats(); - const exportHistory = await this.getExportHistory(); - const importHistory = await this.getImportHistory(); - const storageInfo = typeof storage.getStorageInfo === 'function' ? await storage.getStorageInfo() : null; - - const recordsArray = Array.isArray(practiceRecords) ? practiceRecords : []; - - return { - practiceRecords: { - count: recordsArray.length, - oldestRecord: recordsArray.length ? recordsArray[0]?.startTime : null, - newestRecord: recordsArray.length ? recordsArray[recordsArray.length - 1]?.startTime : null - }, - userStats: { - totalPractices: userStats?.totalPractices ?? 0, - totalTimeSpent: userStats?.totalTimeSpent ?? 0, - averageScore: userStats?.averageScore ?? 0 - }, - exportHistory: { - count: exportHistory.length, - lastExport: exportHistory.length ? exportHistory[0].timestamp : null - }, - importHistory: { - count: importHistory.length, - lastImport: importHistory.length ? importHistory[0].timestamp : null - }, - storage: storageInfo - }; - } catch (error) { - console.error('[DataBackupManager] failed to collect stats', error); - return null; - } - } - - setupPeriodicCleanup() { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - } - - this.cleanupTimer = setInterval(() => { - this.cleanupExpiredData().catch(error => console.error('[DataBackupManager] cleanup failed', error)); - }, 24 * 60 * 60 * 1000); - } - - async cleanupExpiredData() { - try { - const limit = 30 * 24 * 60 * 60 * 1000; - const now = Date.now(); - - const exportHistory = await storage.get(this.storageKeys.exportHistory, []); - const freshExports = exportHistory.filter(item => now - new Date(item.timestamp).getTime() < limit); - if (freshExports.length !== exportHistory.length) { - await storage.set(this.storageKeys.exportHistory, freshExports); - } - - const importHistory = await storage.get(this.storageKeys.importHistory, []); - const freshImports = importHistory.filter(item => now - new Date(item.timestamp).getTime() < limit); - if (freshImports.length !== importHistory.length) { - await storage.set(this.storageKeys.importHistory, freshImports); - } - } catch (error) { - console.error('[DataBackupManager] cleanup error', error); - } - } - - getTimestamp() { - return new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); - } - - toCamelCaseKey(key) { - return String(key) - .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase()) - .replace(/^[A-Z]/, match => match.toLowerCase()); - } - - isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]'; - } -} - -window.DataBackupManager = DataBackupManager; diff --git a/js/utils/safeObjectLiteralParser.js b/js/utils/safeObjectLiteralParser.js new file mode 100644 index 00000000..bf3cd185 --- /dev/null +++ b/js/utils/safeObjectLiteralParser.js @@ -0,0 +1,300 @@ +(function (root, factory) { + 'use strict'; + + var api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root) { + root.SafeObjectLiteralParser = api; + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + var DEFAULT_LIMITS = Object.freeze({ + maxInputLength: 1024 * 1024, + maxDepth: 40, + maxProperties: 5000, + maxStringLength: 256 * 1024 + }); + function ParseError(message, index) { + this.name = 'SafeObjectLiteralParseError'; + this.message = message + ' at index ' + index; + this.index = index; + if (Error.captureStackTrace) Error.captureStackTrace(this, ParseError); + } + ParseError.prototype = Object.create(Error.prototype); + ParseError.prototype.constructor = ParseError; + + function makeLimits(options) { + options = options || {}; + var limits = {}; + Object.keys(DEFAULT_LIMITS).forEach(function (key) { + var configured = Number(options[key]); + limits[key] = Number.isFinite(configured) && configured > 0 + ? Math.floor(configured) + : DEFAULT_LIMITS[key]; + }); + return limits; + } + + function Parser(source, options) { + if (typeof source !== 'string') throw new TypeError('source must be a string'); + this.source = source; + this.length = source.length; + this.index = 0; + this.depth = 0; + this.propertyCount = 0; + this.limits = makeLimits(options); + if (this.length > this.limits.maxInputLength) { + throw new ParseError('input exceeds maximum length', 0); + } + } + + Parser.prototype.fail = function (message) { + throw new ParseError(message, this.index); + }; + + Parser.prototype.skipSpace = function () { + while (this.index < this.length) { + var ch = this.source.charAt(this.index); + if (/\s/.test(ch)) { + this.index++; + continue; + } + if (ch === '/' && this.source.charAt(this.index + 1) === '/') { + this.index += 2; + while (this.index < this.length && !/[\r\n]/.test(this.source.charAt(this.index))) { + this.index++; + } + continue; + } + if (ch === '/' && this.source.charAt(this.index + 1) === '*') { + var end = this.source.indexOf('*/', this.index + 2); + if (end < 0) this.fail('unterminated block comment'); + this.index = end + 2; + continue; + } + break; + } + }; + + Parser.prototype.enter = function () { + this.depth++; + if (this.depth > this.limits.maxDepth) this.fail('maximum nesting depth exceeded'); + }; + + Parser.prototype.leave = function () { + this.depth--; + }; + + Parser.prototype.countProperty = function () { + this.propertyCount++; + if (this.propertyCount > this.limits.maxProperties) { + this.fail('maximum property count exceeded'); + } + }; + + Parser.prototype.parseString = function () { + var quote = this.source.charAt(this.index++); + var result = ''; + while (this.index < this.length) { + var ch = this.source.charAt(this.index++); + if (ch === quote) return result; + if (ch === '\r' || ch === '\n') this.fail('unescaped newline in string'); + if (ch !== '\\') { + result += ch; + } else { + if (this.index >= this.length) this.fail('unterminated string escape'); + var escape = this.source.charAt(this.index++); + var simple = { + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '0': '\0', + '\\': '\\', + '/': '/', + '"': '"', + "'": "'" + }; + if (Object.prototype.hasOwnProperty.call(simple, escape)) { + if (escape === '0' && /[0-9]/.test(this.source.charAt(this.index))) { + this.fail('legacy octal escapes are not supported'); + } + result += simple[escape]; + } else if (escape === 'x') { + var hex = this.source.slice(this.index, this.index + 2); + if (!/^[0-9a-fA-F]{2}$/.test(hex)) this.fail('invalid hex escape'); + result += String.fromCharCode(parseInt(hex, 16)); + this.index += 2; + } else if (escape === 'u') { + var unicode = this.source.slice(this.index, this.index + 4); + if (!/^[0-9a-fA-F]{4}$/.test(unicode)) this.fail('invalid unicode escape'); + result += String.fromCharCode(parseInt(unicode, 16)); + this.index += 4; + } else { + this.fail('unsupported string escape'); + } + } + if (result.length > this.limits.maxStringLength) { + this.fail('string exceeds maximum length'); + } + } + this.fail('unterminated string'); + }; + + Parser.prototype.parseNumber = function () { + var remaining = this.source.slice(this.index); + var match = remaining.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (!match) this.fail('invalid number'); + var next = remaining.charAt(match[0].length); + if (next && /[A-Za-z0-9_$\.]/.test(next)) this.fail('invalid number suffix'); + this.index += match[0].length; + var value = Number(match[0]); + if (!Number.isFinite(value)) this.fail('non-finite numbers are not supported'); + return value; + }; + + Parser.prototype.parseIdentifier = function () { + var match = this.source.slice(this.index).match(/^[A-Za-z_$][A-Za-z0-9_$]*/); + if (!match) this.fail('expected identifier'); + this.index += match[0].length; + return match[0]; + }; + + Parser.prototype.parseKey = function () { + this.skipSpace(); + var ch = this.source.charAt(this.index); + var key; + if (ch === '"' || ch === "'") { + key = this.parseString(); + } else if (/[A-Za-z_$]/.test(ch)) { + key = this.parseIdentifier(); + } else { + var match = this.source.slice(this.index).match(/^(?:0|[1-9]\d*)/); + if (!match) this.fail('object keys must be quoted strings, identifiers, or integers'); + key = match[0]; + this.index += match[0].length; + } + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + this.fail('forbidden object key "' + key + '"'); + } + return key; + }; + + Parser.prototype.parseObject = function () { + var result = Object.create(null); + this.index++; + this.enter(); + this.skipSpace(); + if (this.source.charAt(this.index) === '}') { + this.index++; + this.leave(); + return result; + } + while (this.index < this.length) { + var key = this.parseKey(); + this.countProperty(); + this.skipSpace(); + if (this.source.charAt(this.index) !== ':') { + this.fail('object properties require a colon'); + } + this.index++; + result[key] = this.parseValue(); + this.skipSpace(); + var ch = this.source.charAt(this.index); + if (ch === '}') { + this.index++; + this.leave(); + return result; + } + if (ch !== ',') this.fail('expected comma or closing brace'); + this.index++; + this.skipSpace(); + if (this.source.charAt(this.index) === '}') { + this.index++; + this.leave(); + return result; + } + } + this.fail('unterminated object'); + }; + + Parser.prototype.parseArray = function () { + var result = []; + this.index++; + this.enter(); + this.skipSpace(); + if (this.source.charAt(this.index) === ']') { + this.index++; + this.leave(); + return result; + } + while (this.index < this.length) { + this.countProperty(); + result.push(this.parseValue()); + this.skipSpace(); + var ch = this.source.charAt(this.index); + if (ch === ']') { + this.index++; + this.leave(); + return result; + } + if (ch !== ',') this.fail('expected comma or closing bracket'); + this.index++; + this.skipSpace(); + if (this.source.charAt(this.index) === ']') { + this.index++; + this.leave(); + return result; + } + } + this.fail('unterminated array'); + }; + + Parser.prototype.parseValue = function () { + this.skipSpace(); + var ch = this.source.charAt(this.index); + if (ch === '{') return this.parseObject(); + if (ch === '[') return this.parseArray(); + if (ch === '"' || ch === "'") return this.parseString(); + if (ch === '-' || /[0-9]/.test(ch)) return this.parseNumber(); + if (/[A-Za-z_$]/.test(ch)) { + var identifier = this.parseIdentifier(); + if (identifier === 'true') return true; + if (identifier === 'false') return false; + if (identifier === 'null') return null; + this.fail('unsupported value "' + identifier + '"'); + } + this.fail('unsupported value'); + }; + + function parseAt(source, startIndex, options) { + var parser = new Parser(source, options); + parser.index = Math.max(0, Number(startIndex) || 0); + parser.skipSpace(); + if (parser.source.charAt(parser.index) !== '{') { + parser.fail('expected object literal'); + } + var value = parser.parseObject(); + return { value: value, endIndex: parser.index }; + } + + function parse(source, options) { + var parsed = parseAt(source, 0, options); + var parser = new Parser(source, options); + parser.index = parsed.endIndex; + parser.skipSpace(); + if (parser.index !== parser.length) parser.fail('unexpected trailing input'); + return parsed.value; + } + + return Object.freeze({ + ParseError: ParseError, + parse: parse, + parseAt: parseAt + }); +}); diff --git a/js/utils/simpleStorageWrapper.js b/js/utils/simpleStorageWrapper.js deleted file mode 100644 index 1dd90b1c..00000000 --- a/js/utils/simpleStorageWrapper.js +++ /dev/null @@ -1,183 +0,0 @@ -(function(window) { - class SimpleStorageWrapper { - constructor(repositories) { - this.repos = repositories; - } - - get settingsRepo() { return this.repos.settings; } - get backupRepo() { return this.repos.backups; } - get metaRepo() { return this.repos.meta; } - - isPracticeDataKey(key) { - return key === 'practice_records' || key === 'user_stats'; - } - - getPracticeRecordAPI() { - const api = window.PracticeRecordAPI; - if (!api) { - throw new Error('PracticeRecordAPI unavailable'); - } - return api; - } - - rejectPracticeDataWrite(methodName, targetName) { - throw new Error(`SimpleStorageWrapper.${methodName} is disabled; use ${targetName}`); - } - - async getPracticeRecords() { - const api = this.getPracticeRecordAPI(); - if (typeof api.list !== 'function') { - throw new Error('PracticeRecordAPI.list unavailable'); - } - return await api.list(); - } - - async savePracticeRecords() { - this.rejectPracticeDataWrite('savePracticeRecords', 'PracticeRecordAPI.replace'); - } - - async addPracticeRecord() { - this.rejectPracticeDataWrite('addPracticeRecord', 'PracticeRecordAPI.saveRecord'); - } - - async getById(id) { - const api = this.getPracticeRecordAPI(); - if (typeof api.getById !== 'function') { - throw new Error('PracticeRecordAPI.getById unavailable'); - } - return await api.getById(id); - } - - async update() { - this.rejectPracticeDataWrite('update', 'PracticeRecordAPI.saveRecord'); - } - - async delete() { - this.rejectPracticeDataWrite('delete', 'PracticeRecordAPI.deleteById'); - } - - async deletePracticeRecord() { - this.rejectPracticeDataWrite('deletePracticeRecord', 'PracticeRecordAPI.deleteById'); - } - - async deletePracticeRecords() { - this.rejectPracticeDataWrite('deletePracticeRecords', 'PracticeRecordAPI.deleteMany'); - } - - async getPracticeRecordsCount() { - const records = await this.getPracticeRecords(); - return Array.isArray(records) ? records.length : 0; - } - - validatePracticeRecord(record) { - const errors = []; - if (!record || typeof record !== 'object') { - errors.push('记录必须是对象'); - } else { - if (!record.id || typeof record.id !== 'string') { - errors.push('记录缺少有效的 id'); - } - if (!record.type || typeof record.type !== 'string') { - errors.push('记录缺少有效的 type'); - } - if (record.score === undefined || record.score === null || typeof record.score !== 'number') { - errors.push('记录缺少有效的 score'); - } - if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') { - errors.push('totalQuestions 必须是数字'); - } - if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') { - errors.push('correctAnswers 必须是数字'); - } - if (record.duration !== undefined && typeof record.duration !== 'number') { - errors.push('duration 必须是数字'); - } - if (!record.date) { - errors.push('记录缺少有效的 date'); - } else if (Number.isNaN(new Date(record.date).getTime())) { - errors.push('date 格式无效'); - } - } - return { - isValid: errors.length === 0, - errors - }; - } - - async getUserSettings() { return await this.settingsRepo.getAll(); } - async saveUserSettings(settings) { await this.settingsRepo.saveAll(settings); return true; } - async getUserSetting(key, defaultValue = null) { return await this.settingsRepo.get(key, defaultValue); } - async setUserSetting(key, value) { await this.settingsRepo.set(key, value); return true; } - - async getBackups() { return await this.backupRepo.list(); } - async saveBackups(backups) { await this.backupRepo.saveAll(backups); return true; } - async addBackup(backup) { await this.backupRepo.add(backup); return true; } - async deleteBackup(id) { return await this.backupRepo.delete(id); } - async clearBackups() { await this.backupRepo.clear(); return true; } - - async get(key, defaultValue = null) { - if (this.isPracticeDataKey(key)) { - const api = this.getPracticeRecordAPI(); - if (key === 'practice_records') { - if (typeof api.list !== 'function') { - throw new Error('PracticeRecordAPI.list unavailable'); - } - return await api.list(); - } - if (typeof api.readStats !== 'function') { - throw new Error('PracticeRecordAPI.readStats unavailable'); - } - return await api.readStats({ fallback: defaultValue }); - } - return await this.metaRepo.get(key, defaultValue); - } - - async set(key, value) { - if (this.isPracticeDataKey(key)) { - if (key === 'practice_records') { - this.rejectPracticeDataWrite('set(practice_records)', 'PracticeRecordAPI.replace'); - } - this.rejectPracticeDataWrite('set(user_stats)', 'PracticeRecordAPI.writeStats'); - } - await this.metaRepo.set(key, value); - return true; - } - - async remove(key) { - if (this.isPracticeDataKey(key)) { - if (key === 'practice_records') { - this.rejectPracticeDataWrite('remove(practice_records)', 'PracticeRecordAPI.clear'); - } - this.rejectPracticeDataWrite('remove(user_stats)', 'PracticeRecordAPI.resetStats'); - } - await this.metaRepo.remove(key); - return true; - } - } - - function connectWrapper(repositories) { - if (!repositories) { - return; - } - if (window.simpleStorageWrapper && window.simpleStorageWrapper.repos === repositories) { - return; - } - window.simpleStorageWrapper = new SimpleStorageWrapper(repositories); - console.log('[SimpleStorageWrapper] 已连接新的数据仓库接口'); - } - - const registry = window.StorageProviderRegistry; - if (registry && typeof registry.onProvidersReady === 'function') { - registry.onProvidersReady(({ repositories }) => connectWrapper(repositories)); - const current = registry.getCurrentProviders && registry.getCurrentProviders(); - if (current && current.repositories) { - connectWrapper(current.repositories); - } - } else if (window.dataRepositories) { - connectWrapper(window.dataRepositories); - } else { - console.warn('[SimpleStorageWrapper] 数据仓库尚未可用,等待外部注入'); - } - - window.SimpleStorageWrapper = SimpleStorageWrapper; -})(window); diff --git a/js/utils/stateSerializer.js b/js/utils/stateSerializer.js deleted file mode 100644 index 240f9c52..00000000 --- a/js/utils/stateSerializer.js +++ /dev/null @@ -1,175 +0,0 @@ -/** - * 状态序列化适配器 - * 解决Set/Map对象无法直接JSON序列化的问题 - */ - -class StateSerializer { - /** - * 序列化状态值,处理特殊对象类型 - */ - static serialize(value) { - if (value === null || value === undefined) { - return value; - } - - // 处理Set对象 - if (value instanceof Set) { - return { - __type: 'Set', - __value: Array.from(value) - }; - } - - // 处理Map对象 - if (value instanceof Map) { - return { - __type: 'Map', - __value: Array.from(value.entries()) - }; - } - - // 处理Date对象 - if (value instanceof Date) { - return { - __type: 'Date', - __value: value.toISOString() - }; - } - - // 处理普通对象,递归处理嵌套 - if (typeof value === 'object') { - if (Array.isArray(value)) { - return value.map(item => StateSerializer.serialize(item)); - } else { - const serialized = {}; - for (const [key, val] of Object.entries(value)) { - serialized[key] = StateSerializer.serialize(val); - } - return serialized; - } - } - - // 基本类型直接返回 - return value; - } - - /** - * 反序列化状态值,恢复特殊对象类型 - */ - static deserialize(value) { - if (value === null || value === undefined) { - return value; - } - - // 检查是否是特殊类型对象 - if (typeof value === 'object' && value !== null && '__type' in value) { - switch (value.__type) { - case 'Set': - return new Set(value.__value); - case 'Map': - return new Map(value.__value); - case 'Date': - return new Date(value.__value); - default: - console.warn(`[StateSerializer] 未知类型: ${value.__type}`); - return value.__value; - } - } - - // 处理数组 - if (Array.isArray(value)) { - return value.map(item => StateSerializer.deserialize(item)); - } - - // 处理普通对象,递归处理嵌套 - if (typeof value === 'object') { - const deserialized = {}; - for (const [key, val] of Object.entries(value)) { - deserialized[key] = StateSerializer.deserialize(val); - } - return deserialized; - } - - // 基本类型直接返回 - return value; - } - - /** - * 验证序列化/反序列化的一致性 - */ - static validate(originalValue) { - try { - const serialized = StateSerializer.serialize(originalValue); - const deserialized = StateSerializer.deserialize(serialized); - - // 对于Set/Map,深度比较内容 - if (originalValue instanceof Set) { - const originalArray = Array.from(originalValue); - const deserializedArray = Array.from(deserialized); - return JSON.stringify(originalArray.sort()) === JSON.stringify(deserializedArray.sort()); - } - - if (originalValue instanceof Map) { - const originalArray = Array.from(originalValue.entries()).sort(); - const deserializedArray = Array.from(deserialized.entries()).sort(); - return JSON.stringify(originalArray) === JSON.stringify(deserializedArray); - } - - // 其他类型直接比较 - return JSON.stringify(originalValue) === JSON.stringify(deserialized); - } catch (error) { - console.error('[StateSerializer] 验证失败:', error); - return false; - } - } - - /** - * 创建存储适配器,包装storage对象 - */ - static createStorageAdapter(baseStorage) { - return { - async get(key, defaultValue = null) { - try { - const value = await baseStorage.get(key, defaultValue); - return StateSerializer.deserialize(value); - } catch (error) { - console.error(`[StateSerializer] 获取数据失败 ${key}:`, error); - return defaultValue; - } - }, - - async set(key, value) { - try { - const serializedValue = StateSerializer.serialize(value); - return await baseStorage.set(key, serializedValue); - } catch (error) { - console.error(`[StateSerializer] 设置数据失败 ${key}:`, error); - throw error; - } - }, - - async remove(key) { - try { - return await baseStorage.remove(key); - } catch (error) { - console.error(`[StateSerializer] 删除数据失败 ${key}:`, error); - throw error; - } - }, - - async clear() { - try { - return await baseStorage.clear(); - } catch (error) { - console.error('[StateSerializer] 清空存储失败:', error); - throw error; - } - } - }; - } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = StateSerializer; -} \ No newline at end of file diff --git a/js/utils/storage.js b/js/utils/storage.js deleted file mode 100644 index 26751e35..00000000 --- a/js/utils/storage.js +++ /dev/null @@ -1,2993 +0,0 @@ -(function initStorage(window) { -'use strict'; - -/** - * 本地存储工具类 - * 提供统一的数据存储和检索接口 - */ -const STORAGE_INTERNAL_ACCESS_TOKEN = Symbol('StorageManager.internalAccessToken'); - -const createInternalAccessOptions = (options = {}) => { - return Object.assign({}, options, { - skipPracticeCoreRedirect: true, - internalAccessToken: STORAGE_INTERNAL_ACCESS_TOKEN - }); -}; - -const hasInternalAccessOptions = (options = {}) => { - return Boolean(options && options.internalAccessToken === STORAGE_INTERNAL_ACCESS_TOKEN); -}; - -class StorageManager { - constructor() { - this.prefix = 'exam_system_'; - this.version = '0.6.2-fix'; - this.localStorageAvailable = false; - this.sessionStorageAvailable = false; - this.backendPreferenceKey = this.prefix + 'storage_backend'; - this.indexedDBBlocked = false; - this.volatileMode = false; - this.mode = 'indexeddb'; - this.protectedDataKeys = new Set([ - 'practice_records', - 'user_stats' - ]); - this.persistentKeys = new Set([ - 'practice_records', - 'user_stats', - 'manual_backups', - 'backup_settings', - 'export_history', - 'import_history', - 'exam_index', - 'exam_index_configurations', - 'active_exam_index_key', - 'settings', - 'learning_goals' - ]); - this.ready = this.initializeStorage().catch(error => { - console.error('[Storage] 初始化失败:', error); - throw error; - }); - } - - async waitForInitialization(skipReady = false) { - if (!skipReady) { - await this.ready; - } - } - - isProtectedDataKey(key) { - return this.protectedDataKeys.has(String(key || '')); - } - - isProtectedStorageKey(storageKey) { - const key = String(storageKey || ''); - if (!key.startsWith(this.prefix)) { - return false; - } - return this.isProtectedDataKey(key.slice(this.prefix.length)); - } - - async getPracticeRecordAPI(options = {}) { - const api = window.PracticeRecordAPI; - if (api) { - return api; - } - if (options.skipReady || !this.ready || this._resolvingPracticeRecordAPI) { - return null; - } - this._resolvingPracticeRecordAPI = true; - try { - await this.ready; - return window.PracticeRecordAPI || null; - } finally { - this._resolvingPracticeRecordAPI = false; - } - } - - async readProtectedDataKey(key, defaultValue = null, options = {}) { - const api = await this.getPracticeRecordAPI(options); - if (key === 'practice_records') { - if (api && typeof api.list === 'function') { - return await api.list(); - } - throw new Error('Storage.get(practice_records): PracticeRecordAPI.list not ready'); - } - if (key === 'user_stats') { - if (api && typeof api.readStats === 'function') { - return await api.readStats({ fallback: defaultValue }); - } - throw new Error('Storage.get(user_stats): PracticeRecordAPI.readStats not ready'); - } - return defaultValue; - } - - /** - * 初始化存储系统 - */ - checkStorageAvailability(getter) { - try { - const store = getter(); - if (!store || typeof store.setItem !== 'function') { - return false; - } - const testKey = this.prefix + 'storage_test_' + Math.random().toString(36).slice(2); - store.setItem(testKey, '1'); - store.removeItem(testKey); - return true; - } catch (_) { - return false; - } - } - - getStoredBackendPreference() { - try { - if (this.sessionStorageAvailable && sessionStorage.getItem(this.backendPreferenceKey)) { - return sessionStorage.getItem(this.backendPreferenceKey); - } - } catch (_) { /* ignore */ } - try { - if (this.localStorageAvailable && localStorage.getItem(this.backendPreferenceKey)) { - return localStorage.getItem(this.backendPreferenceKey); - } - } catch (_) { /* ignore */ } - return null; - } - - setBackendPreference(mode) { - try { - if (mode === 'session' && this.sessionStorageAvailable) { - sessionStorage.setItem(this.backendPreferenceKey, 'session'); - return; - } - if (mode === 'local' && this.localStorageAvailable) { - localStorage.setItem(this.backendPreferenceKey, 'local'); - return; - } - } catch (_) { /* ignore */ } - } - - clearBackendPreference() { - try { if (this.sessionStorageAvailable) { sessionStorage.removeItem(this.backendPreferenceKey); } } catch (_) {} - try { if (this.localStorageAvailable) { localStorage.removeItem(this.backendPreferenceKey); } } catch (_) {} - } - - async initializeStorage() { - console.log('[Storage] 开始初始化存储系统'); - try { - this.localStorageAvailable = this.checkStorageAvailability(() => localStorage); - this.sessionStorageAvailable = this.checkStorageAvailability(() => sessionStorage); - if (this.localStorageAvailable) { - console.log('[Storage] localStorage 可用,将使用 localStorage 作为主要存储'); - this.setBackendPreference('local'); - } else { - console.warn('[Storage] localStorage 不可用'); - } - if (this.sessionStorageAvailable) { - console.log('[Storage] sessionStorage 可用,可作为退路'); - } else { - console.warn('[Storage] sessionStorage 不可用'); - } - - const storedPreference = this.getStoredBackendPreference(); - if (storedPreference === 'session') { - this.useSessionStorageFallback = true; - } - if (!this.localStorageAvailable && this.sessionStorageAvailable) { - this.useSessionStorageFallback = true; - } - - // 强制初始化 IndexedDB 以实现 Hybrid 模式,并在版本检查前确保 DB ready - console.log('[Storage] 强制初始化 IndexedDB 以实现 Hybrid 模式'); - await this.initializeIndexedDBStorage(); - - // 初始化版本信息 - const currentVersion = await this.get('system_version', null, { skipReady: true }); - console.log(`[Storage] 当前版本: ${currentVersion}, 目标版本: ${this.version}`); - - if (!currentVersion) { - // 首次安装 - console.log('[Storage] 首次安装,初始化默认数据'); - await this.handleVersionUpgrade(null, { skipReady: true }); - } else if (currentVersion !== this.version) { - // 版本升级 - console.log('[Storage] 版本升级,迁移数据'); - await this.handleVersionUpgrade(currentVersion, { skipReady: true }); - } else { - console.log('[Storage] 版本匹配,跳过初始化'); - } - - // 添加恢复逻辑 - } catch (error) { - console.warn('[Storage] 初始化基本存储能力失败,尝试继续:', error); - await this.initializeIndexedDBStorage(); - } - } - - /** - * 初始化IndexedDB存储 - */ - initializeIndexedDBStorage() { - console.log('[Storage] 开始初始化 IndexedDB'); - if (this.indexedDBBlocked) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - try { - // 检查IndexedDB支持 - if (!window.indexedDB) { - this.indexedDBBlocked = true; - this.indexedDB = null; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - this.volatileMode = false; - this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage'; - console.warn('[Storage] IndexedDB 不支持,将使用现有本地/会话存储'); - resolve(); - return; - } - this.volatileMode = true; - this.mode = 'volatile'; - console.warn('[Storage] IndexedDB 不支持且无本地存储,fallback 到内存存储'); - this.fallbackStorage = new Map(); - resolve(); - return; - } - - this.dbName = 'ExamSystemDB'; - this.dbVersion = 1; - - console.log(`[Storage] 打开 IndexedDB 数据库: ${this.dbName}, 版本: ${this.dbVersion}`); - const request = indexedDB.open(this.dbName, this.dbVersion); - request.addEventListener('error', () => { - this.indexedDB = null; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - this.volatileMode = false; - this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage'; - return; - } - this.volatileMode = true; - this.mode = 'volatile'; - this.fallbackStorage = this.fallbackStorage || new Map(); - }); - request.addEventListener('success', () => { - this.volatileMode = false; - this.mode = 'indexeddb'; - }); - - request.onerror = (event) => { - console.error('[Storage] IndexedDB 打开失败:', event.target.error); - this.indexedDBBlocked = true; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - console.warn('[Storage] 使用 local/sessionStorage 作为回退存储'); - this.indexedDB = null; - resolve(); - return; - } - this.fallbackStorage = new Map(); - resolve(); - }; - - request.onupgradeneeded = (event) => { - console.log('[Storage] IndexedDB 升级事件触发,旧版本:', event.oldVersion, '新版本:', event.newVersion); - const db = event.target.result; - - // 创建存储对象 - if (!db.objectStoreNames.contains('keyValueStore')) { - console.log('[Storage] 创建 objectStore: keyValueStore'); - const store = db.createObjectStore('keyValueStore', { keyPath: 'key' }); - store.createIndex('timestamp', 'timestamp', { unique: false }); - console.log('[Storage] objectStore 创建成功'); - } else { - console.log('[Storage] objectStore 已存在,跳过创建'); - } - }; - - request.onsuccess = (event) => { - this.indexedDB = event.target.result; - this.indexedDBBlocked = false; - console.log('[Storage] IndexedDB 初始化成功,数据库:', this.indexedDB.name, '版本:', this.indexedDB.version); - - // 迁移localStorage数据到IndexedDB - console.log('[Storage] 开始从 localStorage 迁移数据'); - Promise.resolve() - .then(() => this.migrateFromLocalStorage()) - .then(() => resolve()) - .catch((migrationError) => { - console.warn('[Storage] 迁移过程中出现问题,但继续初始化:', migrationError); - resolve(); - }); - }; - - } catch (error) { - console.error('[Storage] IndexedDB 初始化失败:', error); - this.indexedDBBlocked = true; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - console.warn('[Storage] IndexedDB 初始化失败,将使用 local/sessionStorage'); - this.indexedDB = null; - resolve(); - return; - } - this.fallbackStorage = new Map(); - resolve(); - } - }); - } - - /** - * 确保 IndexedDB 已 ready - */ - async ensureIndexedDBReady() { - if (this.indexedDBBlocked) { - return; - } - if (!this.indexedDB) { - try { - await this.initializeIndexedDBStorage(); - } catch (err) { - this.indexedDBBlocked = true; - } - } - } - - async tryPromoteToIndexedDB(serializedValue, key) { - try { - if (!this.indexedDB) { - await this.initializeIndexedDBStorage(); - } - if (this.indexedDB) { - await this.setToIndexedDB(this.getKey(key), serializedValue); - this.useSessionStorageFallback = false; - this.setBackendPreference('local'); - this.dispatchStorageSync(key); - return true; - } - } catch (e) { - console.warn('[Storage] 提升到 IndexedDB 失败,继续使用退路:', e); - } - return false; - } - - /** - * 从localStorage迁移数据到IndexedDB - */ - async migrateFromLocalStorage() { - console.log('[Storage] 开始数据迁移'); - try { - if (!this.indexedDB) { - console.warn('[Storage] IndexedDB 不可用,跳过迁移'); - return; - } - - const keys = Object.keys(localStorage); - const migrationKeys = keys.filter(key => key.startsWith(this.prefix)); - console.log(`[Storage] 发现 ${migrationKeys.length} 条需要迁移的键`); - - if (migrationKeys.length === 0) { - console.log('[Storage] 无数据需要迁移'); - return; - } - - let migratedCount = 0; - let failedCount = 0; - - for (const key of migrationKeys) { - try { - const value = localStorage.getItem(key); - if (value) { - await this.setToIndexedDB(key, value); - localStorage.removeItem(key); - migratedCount++; - console.log(`[Storage] 成功迁移键: ${key}`); - } - } catch (error) { - console.warn(`[Storage] 迁移数据失败: ${key}`, error); - failedCount++; - } - } - - console.log(`[Storage] 数据迁移完成: ${migratedCount} 成功, ${failedCount} 失败`); - } catch (error) { - console.error('[Storage] 数据迁移失败:', error); - } - } - - /** - * 存储到IndexedDB - */ - setToIndexedDB(key, value) { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite'); - const store = transaction.objectStore('keyValueStore'); - - const data = { - key: key, - value: value, - timestamp: Date.now() - }; - - const request = store.put(data); - - request.onsuccess = () => resolve(true); - request.onerror = () => reject(request.error); - }); - } - - /** - * 从IndexedDB获取数据 - */ - getFromIndexedDB(key) { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly'); - const store = transaction.objectStore('keyValueStore'); - const request = store.get(key); - - request.onsuccess = () => { - if (request.result) { - resolve(request.result.value); - } else { - resolve(null); - } - }; - request.onerror = () => reject(request.error); - }); - } - - /** - * 从IndexedDB删除数据 - */ - removeFromIndexedDB(key) { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite'); - const store = transaction.objectStore('keyValueStore'); - const request = store.delete(key); - - request.onsuccess = () => resolve(true); - request.onerror = () => reject(request.error); - }); - } - - /** - * 处理版本升级 - */ - async handleVersionUpgrade(oldVersion, options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - console.log(`Upgrading storage from ${oldVersion || 'unknown'} to ${this.version}`); - - // 在这里处理数据迁移逻辑 - if (!oldVersion) { - // 首次安装,初始化默认数据 - await this.initializeDefaultData({ skipReady }); - } - - await this.set('system_version', this.version, { skipReady }); - - // 执行遗留数据迁移(只运行一次) - if (!await this.get('migration_completed', null, { skipReady })) { - console.log('[Storage] 检测到未完成迁移,开始执行...'); - await this.migrateLegacyData({ skipReady }); - } else { - console.log('[Storage] 迁移已完成,跳过'); - } - } - - /** - * 初始化默认数据 - */ - async initializeDefaultData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - const defaultData = { - settings: { - theme: 'light', - notifications: true, - autoSave: true, - reminderTime: '19:00' - }, - exam_index: null, - learning_goals: [] - }; - - for (const [key, value] of Object.entries(defaultData)) { - const existingValue = await this.get(key, null, { skipReady }); - if (existingValue === null || existingValue === undefined) { - console.log(`[Storage] 初始化默认数据: ${key}`); - await this.set(key, value, { skipReady }); - } else { - console.log(`[Storage] 保留现有数据: ${key} (${Array.isArray(existingValue) ? existingValue.length + ' 项' : typeof existingValue})`); - } - } - } - - /** - * 设置存储命名空间 - */ - setNamespace(namespace) { - if (typeof namespace === 'string' && namespace.trim()) { - this.prefix = namespace.trim() + '_'; - console.log('[Storage] 命名空间已设置为:', this.prefix); - } else { - console.warn('[Storage] 无效的命名空间:', namespace); - } - } - - /** - * 生成完整的存储键名 - */ - getKey(key) { - return this.prefix + key; - } - - createStoredEnvelope(value) { - const compressedValue = this.compressData(value); - return JSON.stringify({ - data: compressedValue, - timestamp: Date.now(), - version: this.version, - compressed: compressedValue !== value - }); - } - - parseStoredEnvelope(serializedValue, defaultValue = undefined) { - if (serializedValue === undefined || serializedValue === null) { - return defaultValue; - } - const parsed = JSON.parse(serializedValue); - return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data') - ? parsed.data - : defaultValue; - } - - readWebStorageValue(storage, storageKey) { - if (!storage || typeof storage.getItem !== 'function') { - return null; - } - try { - return storage.getItem(storageKey); - } catch (_) { - return null; - } - } - - writeWebStorageValue(storage, storageKey, serializedValue) { - if (!storage || typeof storage.setItem !== 'function') { - return false; - } - try { - storage.setItem(storageKey, serializedValue); - return true; - } catch (_) { - return false; - } - } - - async writePersistentValue(key, value, options = {}) { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - throw new Error(`Storage.writePersistentValue(${key}) is internal-only`); - } - const serializedValue = this.createStoredEnvelope(value); - const storageKey = this.getKey(key); - - if (this.indexedDB && !this.indexedDBBlocked) { - await this.setToIndexedDB(storageKey, serializedValue); - this.dispatchStorageSync(key); - return true; - } - - if (this.localStorageAvailable && this.writeWebStorageValue(localStorage, storageKey, serializedValue)) { - this.mode = 'localStorage'; - this.volatileMode = false; - this.dispatchStorageSync(key); - return true; - } - - if (this.sessionStorageAvailable && this.writeWebStorageValue(sessionStorage, storageKey, serializedValue)) { - this.mode = 'sessionStorage'; - this.volatileMode = false; - this.dispatchStorageSync(key); - return true; - } - - if (this.fallbackStorage) { - this.fallbackStorage.set(storageKey, serializedValue); - this.dispatchStorageSync(key); - return true; - } - - this.volatileMode = true; - this.mode = 'volatile'; - this.fallbackStorage = this.fallbackStorage || new Map(); - this.fallbackStorage.set(storageKey, serializedValue); - this.dispatchStorageSync(key); - return true; - } - - async readPersistentValue(key, defaultValue = undefined, options = {}) { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - throw new Error(`Storage.readPersistentValue(${key}) is internal-only`); - } - const storageKey = this.getKey(key); - - if (this.fallbackStorage && this.fallbackStorage.has(storageKey)) { - return this.parseStoredEnvelope(this.fallbackStorage.get(storageKey), defaultValue); - } - - if (this.indexedDB && !this.indexedDBBlocked) { - const serializedValue = await this.getFromIndexedDB(storageKey); - return this.parseStoredEnvelope(serializedValue, defaultValue); - } - - if (this.localStorageAvailable) { - return this.parseStoredEnvelope(this.readWebStorageValue(localStorage, storageKey), defaultValue); - } - - if (this.sessionStorageAvailable) { - return this.parseStoredEnvelope(this.readWebStorageValue(sessionStorage, storageKey), defaultValue); - } - - return defaultValue; - } - - async removePersistentValue(key, options = {}) { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - throw new Error(`Storage.removePersistentValue(${key}) is internal-only`); - } - const storageKey = this.getKey(key); - - if (this.fallbackStorage) { - this.fallbackStorage.delete(storageKey); - } - - if (this.indexedDB && !this.indexedDBBlocked) { - await this.removeFromIndexedDB(storageKey); - } - - try { localStorage.removeItem(storageKey); } catch (_) { } - try { sessionStorage.removeItem(storageKey); } catch (_) { } - this.dispatchStorageSync(key); - return true; - } - - async clearPersistentStorage(options = {}) { - if (!hasInternalAccessOptions(options)) { - throw new Error('Storage.clearPersistentStorage is internal-only'); - } - if (this.fallbackStorage) { - this.fallbackStorage.clear(); - } - - if (this.indexedDB && !this.indexedDBBlocked) { - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite'); - const store = transaction.objectStore('keyValueStore'); - const request = store.clear(); - await new Promise((resolve, reject) => { - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } - - try { - Object.keys(localStorage) - .filter((key) => key.startsWith(this.prefix)) - .forEach((key) => localStorage.removeItem(key)); - } catch (_) { } - try { - Object.keys(sessionStorage) - .filter((key) => key.startsWith(this.prefix)) - .forEach((key) => sessionStorage.removeItem(key)); - } catch (_) { } - - this.clearBackendPreference(); - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key: '*' } })); - return true; - } - - /** - * 压缩数据 - */ - compressData(data) { - try { - // 切记:不要压缩数组,避免把列表写坏 - if (Array.isArray(data)) { - return data; - } - // 仅对体积较大的“对象记录”压缩 - if (data && typeof data === 'object') { - const len = JSON.stringify(data).length; - if (len > 1000) { - return this.compressObject(data); - } - } - return data; - } catch (error) { - console.warn('[Storage] 数据压缩失败,使用原始数据:', error); - return data; - } - } - - /** - * 压缩对象数据 - */ - compressObject(obj) { - // 只保留核心字段:用户答案、canonical 正确答案表、正误、得分、正确率、答题时长、答题时间 - const coreFields = [ - 'id', 'examId', 'title', 'category', 'frequency', - 'score', 'totalQuestions', 'correctAnswers', 'correctAnswerMap', 'accuracy', 'percentage', 'duration', - 'startTime', 'endTime', 'date', 'sessionId', 'timestamp', - 'dataSource', 'realData' - ]; - - const compressed = {}; - - // 只保留核心字段 - coreFields.forEach(field => { - if (obj.hasOwnProperty(field)) { - compressed[field] = obj[field]; - } - }); - - // 压缩realData,只保留核心内容 - if (obj.realData) { - compressed.realData = this.compressRealData(obj.realData); - } - - return compressed; - } - - /** - * 合并记录数组,避免重复 - * 基于 id 去重,保留最新的记录(按 updatedAt/createdAt/endTime/startTime 多字段回退) - */ - mergeRecords(current, legacy) { - if (!Array.isArray(current)) current = []; - if (!Array.isArray(legacy)) return current; - - // canonical 记录的主要时间字段是 updatedAt/createdAt/endTime/startTime, - // 不保证有顶层 timestamp。用多字段回退取最大时间戳,避免保留旧副本丢新副本。 - const resolveTimestamp = (record) => { - if (!record || typeof record !== 'object') return 0; - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.date, - record.timestamp - ]; - for (let i = 0; i < candidates.length; i += 1) { - const value = candidates[i]; - if (!value) continue; - const time = new Date(value).getTime(); - if (Number.isFinite(time)) return time; - } - return 0; - }; - - const mergedMap = new Map(); - [...current, ...legacy].forEach(record => { - if (record && record.id) { - const existing = mergedMap.get(record.id); - if (!existing || (resolveTimestamp(record) > resolveTimestamp(existing))) { - mergedMap.set(record.id, record); - } - } else if (record && record.timestamp) { - // 如果无 id,使用 timestamp 过滤 - mergedMap.set(record.timestamp, record); - } - }); - - return Array.from(mergedMap.values()).sort((a, b) => resolveTimestamp(b) - resolveTimestamp(a)); - } - - async listPracticeRecordsCanonical(options = {}) { - const { skipReady = false } = options; - - const api = await this.getPracticeRecordAPI({ skipReady }); - if (api && typeof api.list === 'function') { - const records = await api.list(); - return Array.isArray(records) ? records : []; - } - - throw new Error('Storage.listPracticeRecordsCanonical: unified store not ready'); - } - - async replacePracticeRecordsCanonical(records, options = {}) { - const { skipReady = false, updateStats } = options; - if (!Array.isArray(records)) { - throw new Error('Storage.replacePracticeRecordsCanonical requires an array of records'); - } - - const api = await this.getPracticeRecordAPI({ skipReady }); - if (api && typeof api.replace === 'function') { - // 透传 updateStats 选项:导入/回滚场景同时写入 user_stats, - // 若此处 recalculateStats 会和并发 writeUserStatsCanonical 竞争,谁后写谁生效。 - // 默认 undefined 让 api.replace 自行决定(保存路径会重算),导入路径传 false 跳过。 - await api.replace(records, { maxRecords: 1000, updateStats }); - return true; - } - - throw new Error('Storage.replacePracticeRecordsCanonical: unified store not ready'); - } - - async writeUserStatsCanonical(stats, options = {}) { - const { skipReady = false } = options; - const api = await this.getPracticeRecordAPI({ skipReady }); - if (api && typeof api.writeStats === 'function') { - return await api.writeStats(stats); - } - throw new Error('Storage.writeUserStatsCanonical: unified stats store not ready'); - } - - async mergePracticeRecordsCanonical(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('Storage.mergePracticeRecordsCanonical requires an array of records'); - } - const current = await this.listPracticeRecordsCanonical(options); - const merged = this.mergeRecords(current, records); - await this.replacePracticeRecordsCanonical(merged, options); - return merged; - } - - /** - * 压缩realData数据 - */ - compressRealData(realData) { - const compressed = { - score: realData.score, - totalQuestions: realData.totalQuestions, - accuracy: realData.accuracy, - percentage: realData.percentage, - duration: realData.duration, - answers: realData.answers || {}, - correctAnswerMap: realData.correctAnswerMap || {}, - isRealData: realData.isRealData, - source: realData.source - }; - - // 压缩答案历史,只保留每个题目的最后一次答案 - if (realData.answerHistory) { - const latestAnswers = {}; - Object.entries(realData.answerHistory).forEach(([questionId, history]) => { - if (Array.isArray(history) && history.length > 0) { - latestAnswers[questionId] = history[history.length - 1]; - } - }); - compressed.answerHistory = latestAnswers; - } - - // 压缩交互记录,只保留最近50次 - if (realData.interactions && Array.isArray(realData.interactions)) { - compressed.interactions = realData.interactions.slice(-50); - } - - // 压缩详细的题目比较信息 - if (realData.answerComparison) { - const simplifiedComparison = {}; - Object.entries(realData.answerComparison).forEach(([questionId, comparison]) => { - simplifiedComparison[questionId] = { - userAnswer: comparison.userAnswer || '', - isCorrect: typeof comparison.isCorrect === 'boolean' ? comparison.isCorrect : null - }; - }); - compressed.answerComparison = simplifiedComparison; - } - - return compressed; - } - - /** - * 存储数据 - */ - async set(key, value, options = {}) { - const { skipReady = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - throw new Error(`Storage.set(${key}) is disabled; use PracticeRecordAPI`); - } - return await this.writePersistentValue(key, value, options); - } catch (error) { - console.error('[Storage] set 操作错误:', error); - this.handleStorageError(key, value, error, options); - if (protectedPublicAccess) { - throw error; - } - return false; - } - } - - /** - * 向数组追加新项 - * @param {string} key - 存储键名 - * @param {*} value - 要追加的项 - * @returns {Promise} 成功返回 true,失败返回 false - */ - async append(key, value, options = {}) { - const { skipReady = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - throw new Error(`Storage.append(${key}) is disabled; use PracticeRecordAPI`); - } - let currentList = await this.readPersistentValue(key, [], options); - if (!Array.isArray(currentList)) { - currentList = []; - } - currentList.push(value); - return await this.writePersistentValue(key, currentList, options); - } catch (error) { - console.error('[Storage] Append error:', error); - this.handleStorageError(key, value, error, options); - if (protectedPublicAccess) { - throw error; - } - return false; - } - } - - async get(key, defaultValue = null, options = {}) { - const { skipReady = false, skipPracticeCoreRedirect = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - return await this.readProtectedDataKey(key, defaultValue, options); - } - return await this.readPersistentValue(key, defaultValue, options); - } catch (error) { - console.error('Storage get error:', error); - if (protectedPublicAccess) { - throw error; - } - return defaultValue; - } - } - - /** - * 删除数据 - */ - async remove(key, options = {}) { - const { skipReady = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - throw new Error(`Storage.remove(${key}) is disabled; use PracticeRecordAPI`); - } - return await this.removePersistentValue(key, options); - } catch (error) { - console.error('Storage remove error:', error); - if (protectedPublicAccess) { - throw error; - } - return false; - } - } - - /** - * 清空所有数据 - */ - async clear(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (!hasInternalAccessOptions(options)) { - const api = await this.getPracticeRecordAPI(options); - if (!api || typeof api.clear !== 'function' || typeof api.resetStats !== 'function') { - throw new Error('Storage.clear: PracticeRecordAPI clear/resetStats not ready'); - } - await api.clear({ updateStats: false }); - await api.resetStats(); - } - return await this.clearPersistentStorage(createInternalAccessOptions(options)); - } catch (error) { - console.error('Storage clear error:', error); - return false; - } - } - - /** - * 检查存储配额是否充足 - */ - async checkStorageQuota(dataSize, options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - console.log(`[Storage] 检查存储配额,需要空间: ${dataSize} 字节`); - if (this.fallbackStorage) { - console.log('[Storage] 内存存储,无配额限制'); - return true; // 内存存储没有配额限制 - } - - const storageInfo = await this.getStorageInfo({ skipReady }); - if (!storageInfo) { - console.warn('[Storage] 无法获取存储信息,拒绝操作'); - return false; - } - - console.log(`[Storage] 当前存储类型: ${storageInfo.type}, 已用: ${storageInfo.used} 字节`); - - if (storageInfo.type === 'Hybrid' || storageInfo.type === 'IndexedDB') { - // 混合存储或IndexedDB没有固定配额限制,但我们仍然检查数据大小 - const maxSize = 105 * 1024 * 1024; // 105MB限制 (localStorage 5MB + IndexedDB 100MB) - const hasSpace = storageInfo.used + dataSize <= maxSize; - console.log(`[Storage] Hybrid/IndexedDB 检查: 已用 ${storageInfo.used}, 需要 ${dataSize}, 最大 ${maxSize}, 结果: ${hasSpace}`); - return hasSpace; - } - - const currentUsage = storageInfo.used; - const quota = 5 * 1024 * 1024; // 5MB - const availableSpace = quota - currentUsage; - - // 预留20%的缓冲空间 - const bufferSpace = quota * 0.2; - const safeAvailableSpace = availableSpace - bufferSpace; - - console.log(`[Storage] localStorage 检查: 当前使用 ${(currentUsage / 1024).toFixed(2)}KB, 总配额 ${quota / 1024}KB, 可用 ${(availableSpace / 1024).toFixed(2)}KB, 安全可用 ${(safeAvailableSpace / 1024).toFixed(2)}KB, 需要 ${(dataSize / 1024).toFixed(2)}KB`); - - const hasSpace = safeAvailableSpace >= dataSize; - if (!hasSpace) { - console.warn('[Storage] localStorage 空间不足'); - } - return hasSpace; - } catch (error) { - console.error('[Storage] 配额检查错误:', error); - return false; - } - } - - /** - * 获取存储使用情况 - */ - async getStorageInfo(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - if (this.fallbackStorage) { - return { - type: 'volatile', - mode: this.mode, - volatile: true, - used: this.fallbackStorage.size, - available: Infinity - }; - } - - if (this.indexedDB && !this.indexedDBBlocked) { - const indexedDBUsed = await this.getIndexedDBUsage(); - return { - type: 'indexedDB', - mode: this.mode, - volatile: false, - used: indexedDBUsed, - available: Infinity, - breakdown: { - indexedDB: indexedDBUsed - } - }; - } - - if (this.fallbackStorage) { - return { - type: 'memory', - used: this.fallbackStorage.size, - available: Infinity - }; - } - - if (this.indexedDB) { - try { - // 获取所有存储的使用情况 - const localStorageUsed = this.getLocalStorageUsage(); - const indexedDBUsed = await this.getIndexedDBUsage(); - const totalUsed = localStorageUsed + indexedDBUsed; - - return { - type: 'Hybrid', - used: totalUsed, - available: Infinity, // 混合存储没有固定配额 - breakdown: { - localStorage: localStorageUsed, - indexedDB: indexedDBUsed - } - }; - } catch (error) { - console.warn('[Storage] 获取混合存储使用情况失败:', error); - // 降级到localStorage - } - } - - let used = 0; - const keys = Object.keys(localStorage); - keys.forEach(key => { - if (key.startsWith(this.prefix)) { - used += localStorage.getItem(key).length; - } - }); - - return { - type: 'localStorage', - used: used, - available: 5 * 1024 * 1024 - used // 假设5MB限制 - }; - } catch (error) { - console.error('Storage info error:', error); - return null; - } - } - - /** - * 获取localStorage使用情况 - */ - getLocalStorageUsage() { - try { - let used = 0; - const keys = Object.keys(localStorage); - keys.forEach(key => { - if (key.startsWith(this.prefix)) { - used += localStorage.getItem(key).length; - } - }); - return used; - } catch (error) { - console.error('Get localStorage usage error:', error); - return 0; - } - } - - /** - * 获取IndexedDB使用情况 - */ - getIndexedDBUsage() { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly'); - const store = transaction.objectStore('keyValueStore'); - const request = store.getAll(); - - request.onsuccess = () => { - const items = request.result; - let totalSize = 0; - - items.forEach(item => { - if (item.key.startsWith(this.prefix) && item.value) { - totalSize += item.value.length; - } - }); - - resolve(totalSize); - }; - - request.onerror = () => reject(request.error); - }); - } - - /** - * 清理旧数据 - */ - async cleanupOldData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - console.log('[Storage] 开始清理旧数据...'); - - const practiceRecords = await this.listPracticeRecordsCanonical({ skipReady }); - if (practiceRecords.length > 0) { - console.log(`[Storage] 练习记录数据保留${practiceRecords.length}条记录,跳过压缩以保护答案数据完整性`); - } - - // 清理错误日志 - const errorLogs = await this.get('injection_errors', [], { skipReady }); - if (errorLogs.length > 20) { - const logsToKeep = errorLogs.slice(-20); // 保留最近20条 - await this.set('injection_errors', logsToKeep, { skipReady }); - console.log(`[Storage] 已清理错误日志,从${errorLogs.length}条减少到${logsToKeep.length}条`); - } - - const collectionErrors = await this.get('collection_errors', [], { skipReady }); - if (collectionErrors.length > 20) { - const logsToKeep = collectionErrors.slice(-20); - await this.set('collection_errors', logsToKeep, { skipReady }); - console.log(`[Storage] 已清理数据收集错误日志,从${collectionErrors.length}条减少到${logsToKeep.length}条`); - } - - // 清理活动会话(保留最近的) - const activeSessions = await this.get('active_sessions', [], { skipReady }); - const now = Date.now(); - const recentSessions = activeSessions.filter(session => { - const sessionTime = new Date(session.startTime).getTime(); - const hoursDiff = (now - sessionTime) / (1000 * 60 * 60); - return hoursDiff < 1; // 只保留1小时内的会话 - }); - - if (recentSessions.length !== activeSessions.length) { - await this.set('active_sessions', recentSessions, { skipReady }); - console.log(`[Storage] 已清理过期会话,从${activeSessions.length}个减少到${recentSessions.length}个`); - } - - } catch (error) { - console.error('[Storage] 清理旧数据失败:', error); - } - } - - /** - * 迁移遗留数据到新命名空间 - * 只运行一次 - */ - async migrateLegacyData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - console.log('[Storage] 开始迁移遗留数据'); - try { - const legacyKeys = Object.keys(localStorage).filter(k => - k === 'practice_records' || - k === 'user_progress' || - k === 'scores' || - k.startsWith('old_prefix_') - ); - - if (legacyKeys.length === 0) { - console.log('[Storage] 无遗留数据需要迁移'); - await this.set('migration_completed', true, { skipReady }); - } else { - let migratedCount = 0; - let deferredPracticeMigration = false; - for (const oldKey of legacyKeys) { - try { - const legacyDataStr = localStorage.getItem(oldKey); - if (!legacyDataStr) continue; - - let legacyData; - try { - legacyData = JSON.parse(legacyDataStr); - } catch (parseError) { - console.warn(`[Storage] 解析遗留数据失败: ${oldKey}`, parseError); - continue; - } - - if (!Array.isArray(legacyData)) { - console.warn(`[Storage] 遗留数据非数组,跳过: ${oldKey}`); - continue; - } - - if (legacyData.length === 0) { - console.log('[Storage] 旧数据为空,跳过迁移'); - continue; - } - - // 对应新键(去除 old_prefix_ 如果存在) - let newKey = oldKey.replace(/^old_prefix_/, ''); - const isPracticeRecordsKey = newKey === 'practice_records'; - if (isPracticeRecordsKey) { - await this.mergePracticeRecordsCanonical(legacyData, { skipReady }); - } else { - const current = await this.get(newKey, [], { skipReady }); - const merged = this.mergeRecords(current, legacyData); - await this.set(newKey, merged, { skipReady }); - } - - // 删除旧键 - localStorage.removeItem(oldKey); - migratedCount++; - console.log(`[Storage] 成功迁移并合并数据: ${oldKey} -> ${newKey} (${legacyData.length} 项)`); - } catch (migrateError) { - const newKey = oldKey.replace(/^old_prefix_/, ''); - if (newKey === 'practice_records') { - deferredPracticeMigration = true; - } - console.error(`[Storage] 迁移失败: ${oldKey}`, migrateError); - } - } - - console.log(`[Storage] 数据迁移完成: ${migratedCount} 个键成功迁移`); - if (deferredPracticeMigration) { - console.warn('[Storage] 练习记录迁移已延后,等待 PracticeRecordAPI 就绪后重试'); - } else { - await this.set('migration_completed', true, { skipReady }); - } - } - - if (!await this.get('my_melody_migration_completed', null, { skipReady })) { - console.log('[Storage] 检查 MyMelody 遗留键迁移...'); - const canonicalPracticeKey = this.getKey('practice_records'); - console.warn('[Storage] 跳过 MyMelody 遗留键迁移:旧键与 canonical practice_records 键相同,继续迁移会误删当前记录', canonicalPracticeKey); - await this.set('my_melody_migration_completed', true, { skipReady }); - } - - } catch (error) { - console.error('[Storage] 迁移遗留数据失败:', error); - // 即使失败也设置标志,避免无限重试 - await this.set('migration_completed', true, { skipReady }); - } - } - - /** - * 从备份文件恢复数据 - */ - async restoreFromBackup(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - console.log('[Storage] 开始从备份恢复数据'); - - const backupPath = 'assets/data/backup-practice-records.json'; - const isFileProtocol = typeof window !== 'undefined' - && window.location - && window.location.protocol === 'file:'; - - // Chromium 下 fetch(file://...) 会直接抛错;备份属于可选项,跳过即可。 - if (isFileProtocol) { - console.info('[Storage] file:// 环境跳过内置备份恢复'); - return false; - } - - try { - const response = await fetch(backupPath); - if (!response.ok) { - return false; - } - const backupData = await response.json(); - if (!backupData || !Array.isArray(backupData.practice_records)) { - console.warn('[Storage] 备份数据格式无效'); - return false; - } - // 运行期恢复必须走统一记录 API;raw practice_records 只允许启动迁移兼容使用。 - await this.replacePracticeRecordsCanonical(backupData.practice_records, { skipReady }); - console.log('[Storage] 从备份恢复 practice_records 成功'); - return true; - } catch (error) { - console.warn('[Storage] 备份恢复失败,已跳过:', error); - return false; - } - } - - /** - * 处理存储错误 - */ - handleStorageError(key, value, error, options = {}) { - console.error('[Storage] 存储错误:', error); - - // 如果是配额错误,尝试切换到备用存储 - if (error.name === 'QuotaExceededError') { - this.handleStorageQuotaExceeded(key, value, options); - } else { - // 其他错误 - if (window.showMessage) { - window.showMessage('数据保存失败,请检查浏览器设置', 'error'); - } - - // 触发存储错误事件 - document.dispatchEvent(new CustomEvent('storageError', { - detail: { key, value, error } - })); - } - } - - /** - * 导出数据 - */ - async exportData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - const data = {}; - - // 1. 导出内存存储数据 - if (this.fallbackStorage) { - this.fallbackStorage.forEach((value, key) => { - if (key.startsWith(this.prefix)) { - if (this.isProtectedStorageKey(key)) { - return; - } - const cleanKey = key.replace(this.prefix, ''); - data[cleanKey] = JSON.parse(value); - } - }); - console.log(`[Storage] 已导出内存存储数据 ${this.fallbackStorage.size} 条`); - } - - // 2. 导出IndexedDB数据 - if (this.indexedDB) { - try { - const items = await this.getAllFromIndexedDB(); - const indexedDBData = {}; - items.forEach(item => { - if (item.key.startsWith(this.prefix) && !this.isProtectedStorageKey(item.key)) { - const cleanKey = item.key.replace(this.prefix, ''); - indexedDBData[cleanKey] = JSON.parse(item.value); - } - }); - // 合并IndexedDB数据 - Object.assign(data, indexedDBData); - console.log(`[Storage] 已导出IndexedDB数据 ${Object.keys(indexedDBData).length} 条`); - } catch (error) { - console.warn('[Storage] IndexedDB导出失败:', error); - } - } - - // 3. 导出localStorage数据 - const localStorageKeys = Object.keys(localStorage); - const appKeys = localStorageKeys.filter(key => key.startsWith(this.prefix)); - appKeys.forEach(key => { - const cleanKey = key.replace(this.prefix, ''); - if (this.isProtectedDataKey(cleanKey)) { - return; - } - try { - const value = localStorage.getItem(key); - if (value) { - data[cleanKey] = JSON.parse(value); - } - } catch (error) { - console.warn(`[Storage] 解析localStorage数据失败: ${cleanKey}`, error); - } - }); - console.log(`[Storage] 已导出localStorage数据 ${appKeys.length} 条`); - - data.practice_records = await this.readProtectedDataKey('practice_records', [], { skipReady }); - data.user_stats = await this.readProtectedDataKey('user_stats', null, { skipReady }); - - console.log(`[Storage] 数据导出完成,总计 ${Object.keys(data).length} 条记录`); - - return { - version: this.version, - exportDate: new Date().toISOString(), - data: data, - storageInfo: { - totalRecords: Object.keys(data).length, - sources: { - memory: this.fallbackStorage ? this.fallbackStorage.size : 0, - indexedDB: this.indexedDB ? Object.keys(data).length - (this.fallbackStorage ? this.fallbackStorage.size : 0) - appKeys.length : 0, - localStorage: appKeys.length - } - } - }; - } catch (error) { - console.error('Export data error:', error); - return null; - } - } - - /** - * 从IndexedDB获取所有数据 - */ - getAllFromIndexedDB() { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly'); - const store = transaction.objectStore('keyValueStore'); - const request = store.getAll(); - - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - - /** - * 导入数据 - */ - async importData(importedData, options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - if (!importedData || !importedData.data) { - throw new Error('Invalid import data format'); - } - - const importEntries = Object.entries(importedData.data); - const api = await this.getPracticeRecordAPI({ skipReady }); - const hasPracticeRecords = importEntries.some(([key]) => key === 'practice_records'); - const hasUserStats = importEntries.some(([key]) => key === 'user_stats'); - if (hasPracticeRecords && (!api || typeof api.replace !== 'function')) { - throw new Error('Storage.importData: unified practice record store not ready'); - } - if (hasUserStats && (!api || typeof api.writeStats !== 'function')) { - throw new Error('Storage.importData: unified user stats store not ready'); - } - - // 备份当前数据 - const backup = await this.exportData({ skipReady }); - const importEntry = ([key, value]) => { - const nextValue = value && Object.prototype.hasOwnProperty.call(value, 'data') - ? value.data - : value; - if (key === 'practice_records') { - // updateStats: false — 导入时 user_stats 会通过 writeUserStatsCanonical 独立写入, - // 若此处 recalculateStats 会和并发写入竞争,导致备份中的统计值被覆盖。 - return this.replacePracticeRecordsCanonical(nextValue, { skipReady, updateStats: false }); - } - if (key === 'user_stats') { - return this.writeUserStatsCanonical(nextValue, { skipReady }); - } - return this.set(key, nextValue, { skipReady }); - }; - - try { - // 清空现有数据 - await this.clear({ skipReady }); - - // 导入新数据 - const importPromises = importEntries.map(importEntry); - - await Promise.all(importPromises); - - return { success: true, message: 'Data imported successfully' }; - } catch (importError) { - // 恢复备份 - console.error('Import failed, restoring backup:', importError); - await this.clear({ skipReady }); - - if (backup && backup.data) { - const restorePromises = Object.entries(backup.data).map(importEntry); - await Promise.all(restorePromises); - } - - throw importError; - } - } catch (error) { - console.error('Import data error:', error); - return { success: false, message: error.message }; - } - } - - /** - * 数据验证 - */ - validateData(key, data) { - const validators = { - practice_records: (records) => { - return Array.isArray(records) && records.every(record => - record.id && record.examId && record.startTime && record.endTime - ); - }, - user_stats: (stats) => { - return stats && typeof stats.totalPractices === 'number'; - }, - exam_index: (index) => { - return !index || (Array.isArray(index) && index.every(exam => - exam.id && exam.title && exam.category - )); - } - }; - - const validator = validators[key]; - return validator ? validator(data) : true; - } - - /** - * 启动存储监控 - */ - async startStorageMonitoring() { - await this.waitForInitialization(); - console.log('[Storage] 启动存储监控...'); - - // 定期检查存储使用情况 - this.monitoringInterval = setInterval(async () => { - try { - const storageInfo = await this.getStorageInfo(); - if (storageInfo) { - const usagePercent = storageInfo.type === 'localStorage' - ? (storageInfo.used / (5 * 1024 * 1024)) * 100 - : (storageInfo.used / (105 * 1024 * 1024)) * 100; - - const maxSize = storageInfo.type === 'localStorage' ? '5MB' : - storageInfo.type === 'Hybrid' ? '105MB' : '100MB'; - console.log(`[Storage] 使用率: ${usagePercent.toFixed(2)}% (${(storageInfo.used / 1024).toFixed(2)}KB / ${maxSize})`); - - // 显示详细的存储分布 - if (storageInfo.breakdown) { - console.log(`[Storage] 存储分布: localStorage ${(storageInfo.breakdown.localStorage / 1024).toFixed(2)}KB, IndexedDB ${(storageInfo.breakdown.indexedDB / 1024).toFixed(2)}KB`); - } - - // 当使用率超过80%时,自动清理 - if (usagePercent > 80) { - console.warn('[Storage] 存储使用率过高,自动清理旧数据'); - await this.cleanupOldData(); - - // 清理后再次检查 - const newStorageInfo = await this.getStorageInfo(); - if (newStorageInfo) { - const newUsagePercent = newStorageInfo.type === 'localStorage' - ? (newStorageInfo.used / (5 * 1024 * 1024)) * 100 - : (newStorageInfo.used / (105 * 1024 * 1024)) * 100; - - console.log(`[Storage] 清理后使用率: ${newUsagePercent.toFixed(2)}%`); - - // 如果仍然超过90%,显示警告 - if (newUsagePercent > 90) { - if (window.showMessage) { - window.showMessage('存储空间即将不足,建议导出数据备份', 'warning'); - } - } - } - } - } - } catch (error) { - console.error('[Storage] 存储监控错误:', error); - } - }, 300000); // 每5分钟检查一次 - - // 页面卸载时清理监控 - 全局事件必须使用原生 addEventListener - window.addEventListener('beforeunload', () => { - if (this.monitoringInterval) { - clearInterval(this.monitoringInterval); - } - }); - } - - // ==================== 词表存储专用方法 ==================== - - /** - * 词表存储键常量 - */ - getVocabStorageKeys() { - return { - P1_ERRORS: 'vocab_list_p1_errors', - P4_ERRORS: 'vocab_list_p4_errors', - MASTER_ERRORS: 'vocab_list_master_errors', - CUSTOM: 'vocab_list_custom', - READING_HIGHLIGHTS: 'vocab_list_reading_highlights', - ACTIVE_LIST: 'vocab_active_list' - }; - } - - /** - * 验证词表数据结构 - */ - validateVocabList(vocabList) { - if (!vocabList || typeof vocabList !== 'object') { - return { valid: false, error: '词表数据无效' }; - } - - const requiredFields = ['id', 'name', 'source', 'words', 'createdAt', 'updatedAt']; - for (const field of requiredFields) { - if (!(field in vocabList)) { - return { valid: false, error: `缺少必需字段: ${field}` }; - } - } - - if (!Array.isArray(vocabList.words)) { - return { valid: false, error: 'words 字段必须是数组' }; - } - - // 验证每个单词条目 - for (const word of vocabList.words) { - if (!word.word || typeof word.word !== 'string') { - return { valid: false, error: '单词条目缺少有效的 word 字段' }; - } - if (!word.timestamp || typeof word.timestamp !== 'number') { - return { valid: false, error: '单词条目缺少有效的 timestamp 字段' }; - } - } - - return { valid: true }; - } - - /** - * 清理词表数据 - * 移除重复单词,保留最新的记录 - */ - cleanVocabList(vocabList) { - if (!vocabList || !Array.isArray(vocabList.words)) { - return vocabList; - } - - const wordMap = new Map(); - - // 按时间戳排序,保留最新的 - vocabList.words.forEach(word => { - const key = word.word.toLowerCase().trim(); - const existing = wordMap.get(key); - - if (!existing || word.timestamp > existing.timestamp) { - wordMap.set(key, word); - } - }); - - vocabList.words = Array.from(wordMap.values()); - vocabList.updatedAt = Date.now(); - - return vocabList; - } - - /** - * 保存词表数据 - */ - async saveVocabList(vocabList, options = {}) { - const { skipReady = false } = options; - - try { - // 验证数据 - const validation = this.validateVocabList(vocabList); - if (!validation.valid) { - console.error('[Storage] 词表数据验证失败:', validation.error); - return false; - } - - // 清理数据 - const cleanedList = this.cleanVocabList(vocabList); - - // 确定存储键 - const keys = this.getVocabStorageKeys(); - let storageKey; - - switch (cleanedList.source) { - case 'p1': - storageKey = keys.P1_ERRORS; - break; - case 'p4': - storageKey = keys.P4_ERRORS; - break; - case 'all': - storageKey = keys.MASTER_ERRORS; - break; - case 'user': - storageKey = keys.CUSTOM; - break; - case 'reading-highlight': - storageKey = keys.READING_HIGHLIGHTS; - break; - default: - storageKey = cleanedList.id; - } - - console.log(`[Storage] 保存词表: ${storageKey}, 单词数: ${cleanedList.words.length}`); - - // 保存到存储 - const success = await this.set(storageKey, cleanedList, { skipReady }); - - if (success) { - console.log(`[Storage] 词表保存成功: ${storageKey}`); - } - - return success; - } catch (error) { - console.error('[Storage] 保存词表失败:', error); - return false; - } - } - - /** - * 加载词表数据 - */ - async loadVocabList(listId, options = {}) { - const { skipReady = false } = options; - - try { - const keys = this.getVocabStorageKeys(); - let storageKey; - - // 根据 listId 确定存储键 - if (listId === 'spelling-errors-p1') { - storageKey = keys.P1_ERRORS; - } else if (listId === 'spelling-errors-p4') { - storageKey = keys.P4_ERRORS; - } else if (listId === 'spelling-errors-master') { - storageKey = keys.MASTER_ERRORS; - } else if (listId === 'custom') { - storageKey = keys.CUSTOM; - } else if (listId === 'reading-highlights') { - storageKey = keys.READING_HIGHLIGHTS; - } else { - storageKey = listId; - } - - console.log(`[Storage] 加载词表: ${storageKey}`); - - const vocabList = await this.get(storageKey, null, { skipReady }); - - if (!vocabList) { - console.log(`[Storage] 词表不存在: ${storageKey}`); - return null; - } - - if (Array.isArray(vocabList)) { - const now = new Date().toISOString(); - const sourceMap = { - 'spelling-errors-p1': 'p1', - 'spelling-errors-p4': 'p4', - 'spelling-errors-master': 'all', - 'custom': 'user', - 'reading-highlights': 'reading-highlight' - }; - const nameMap = { - 'spelling-errors-p1': 'P1 拼写错误', - 'spelling-errors-p4': 'P4 拼写错误', - 'spelling-errors-master': '综合错误词表', - 'custom': '自定义词表', - 'reading-highlights': '阅读高亮生词' - }; - return { - id: listId, - name: nameMap[listId] || listId, - source: sourceMap[listId] || listId, - words: vocabList, - createdAt: now, - updatedAt: now - }; - } - - // 验证加载的数据 - const validation = this.validateVocabList(vocabList); - if (!validation.valid) { - console.error('[Storage] 加载的词表数据无效:', validation.error); - return null; - } - - console.log(`[Storage] 词表加载成功: ${storageKey}, 单词数: ${vocabList.words.length}`); - return vocabList; - } catch (error) { - console.error('[Storage] 加载词表失败:', error); - return null; - } - } - - /** - * 获取词表单词数量 - */ - async getVocabListWordCount(listId, options = {}) { - const { skipReady = false } = options; - - try { - const vocabList = await this.loadVocabList(listId, { skipReady }); - return vocabList ? vocabList.words.length : 0; - } catch (error) { - console.error('[Storage] 获取词表单词数量失败:', error); - return 0; - } - } - - /** - * 添加单词到词表 - */ - async addWordToVocabList(listId, word, options = {}) { - const { skipReady = false } = options; - - try { - let vocabList = await this.loadVocabList(listId, { skipReady }); - - if (!vocabList) { - // 创建新词表 - vocabList = { - id: listId, - name: this.getVocabListName(listId), - source: this.getVocabListSource(listId), - words: [], - createdAt: Date.now(), - updatedAt: Date.now() - }; - } - - // 检查单词是否已存在 - const existingIndex = vocabList.words.findIndex(w => - w.word.toLowerCase() === word.word.toLowerCase() - ); - - if (existingIndex >= 0) { - // 更新现有单词 - vocabList.words[existingIndex] = { - ...vocabList.words[existingIndex], - ...word, - errorCount: (vocabList.words[existingIndex].errorCount || 0) + 1, - timestamp: Date.now() - }; - } else { - // 添加新单词 - vocabList.words.push({ - ...word, - errorCount: word.errorCount || 1, - timestamp: word.timestamp || Date.now() - }); - } - - vocabList.updatedAt = Date.now(); - - return await this.saveVocabList(vocabList, { skipReady }); - } catch (error) { - console.error('[Storage] 添加单词到词表失败:', error); - return false; - } - } - - /** - * 从词表中移除单词 - */ - async removeWordFromVocabList(listId, word, options = {}) { - const { skipReady = false } = options; - - try { - const vocabList = await this.loadVocabList(listId, { skipReady }); - - if (!vocabList) { - return false; - } - - const normalizedWord = word.toLowerCase().trim(); - vocabList.words = vocabList.words.filter(w => - w.word.toLowerCase().trim() !== normalizedWord - ); - - vocabList.updatedAt = Date.now(); - - return await this.saveVocabList(vocabList, { skipReady }); - } catch (error) { - console.error('[Storage] 从词表移除单词失败:', error); - return false; - } - } - - /** - * 获取词表名称 - */ - getVocabListName(listId) { - const names = { - 'spelling-errors-p1': 'P1 拼写错误', - 'spelling-errors-p4': 'P4 拼写错误', - 'spelling-errors-master': '综合错误词表', - 'custom': '自定义词表' - }; - return names[listId] || listId; - } - - /** - * 获取词表来源 - */ - getVocabListSource(listId) { - if (listId.includes('p1')) return 'p1'; - if (listId.includes('p4')) return 'p4'; - if (listId.includes('master')) return 'all'; - return 'user'; - } - - /** - * 获取所有词表的元数据 - */ - async getAllVocabListsMetadata(options = {}) { - const { skipReady = false } = options; - - const keys = this.getVocabStorageKeys(); - const listIds = [ - 'spelling-errors-p1', - 'spelling-errors-p4', - 'spelling-errors-master', - 'custom' - ]; - - const metadata = []; - - for (const listId of listIds) { - const count = await this.getVocabListWordCount(listId, { skipReady }); - metadata.push({ - id: listId, - name: this.getVocabListName(listId), - source: this.getVocabListSource(listId), - wordCount: count - }); - } - - return metadata; - } - - // ==================== 数据同步逻辑 ==================== - - /** - * 同步词表数据(跨会话) - * 处理数据冲突,使用最新时间戳 - */ - async syncVocabList(listId, newData, options = {}) { - const { skipReady = false } = options; - - try { - console.log(`[Storage] 开始同步词表: ${listId}`); - - // 加载现有数据 - const existingList = await this.loadVocabList(listId, { skipReady }); - - if (!existingList) { - // 没有现有数据,直接保存新数据 - console.log(`[Storage] 无现有数据,直接保存新词表`); - return await this.saveVocabList(newData, { skipReady }); - } - - // 合并数据,解决冲突 - const mergedList = this.mergeVocabLists(existingList, newData); - - console.log(`[Storage] 词表合并完成,单词数: ${mergedList.words.length}`); - - // 保存合并后的数据 - return await this.saveVocabList(mergedList, { skipReady }); - } catch (error) { - console.error('[Storage] 同步词表失败:', error); - return false; - } - } - - /** - * 合并两个词表,解决冲突 - * 使用最新时间戳的数据 - */ - mergeVocabLists(existing, incoming) { - // 使用最新的元数据 - const merged = { - id: existing.id, - name: existing.name, - source: existing.source, - words: [], - createdAt: existing.createdAt, - updatedAt: Math.max(existing.updatedAt, incoming.updatedAt) - }; - - // 创建单词映射 - const wordMap = new Map(); - - // 先添加现有单词 - existing.words.forEach(word => { - const key = word.word.toLowerCase().trim(); - wordMap.set(key, word); - }); - - // 合并新单词,使用最新时间戳 - incoming.words.forEach(word => { - const key = word.word.toLowerCase().trim(); - const existingWord = wordMap.get(key); - - if (!existingWord || word.timestamp > existingWord.timestamp) { - // 新单词或更新的单词 - wordMap.set(key, { - ...existingWord, - ...word, - errorCount: (existingWord?.errorCount || 0) + (word.errorCount || 1) - }); - } - }); - - merged.words = Array.from(wordMap.values()); - - return merged; - } - - /** - * 批量同步所有词表 - */ - async syncAllVocabLists(options = {}) { - const { skipReady = false } = options; - - try { - console.log('[Storage] 开始批量同步所有词表'); - - const listIds = [ - 'spelling-errors-p1', - 'spelling-errors-p4', - 'spelling-errors-master', - 'custom' - ]; - - const results = []; - - for (const listId of listIds) { - const list = await this.loadVocabList(listId, { skipReady }); - if (list) { - const success = await this.syncVocabList(listId, list, { skipReady }); - results.push({ listId, success }); - } - } - - console.log('[Storage] 批量同步完成:', results); - return results; - } catch (error) { - console.error('[Storage] 批量同步失败:', error); - return []; - } - } - - /** - * 确保数据持久化(页面关闭前) - */ - async ensureDataPersisted(options = {}) { - const { skipReady = false } = options; - - try { - console.log('[Storage] 确保数据持久化'); - - // 强制刷新所有待写入的数据 - if (this.indexedDB) { - // IndexedDB 事务会自动提交,无需额外操作 - console.log('[Storage] IndexedDB 数据已自动持久化'); - } - - // 同步所有词表 - await this.syncAllVocabLists({ skipReady }); - - console.log('[Storage] 数据持久化完成'); - return true; - } catch (error) { - console.error('[Storage] 数据持久化失败:', error); - return false; - } - } - - /** - * 监听页面卸载事件,确保数据持久化 - */ - setupBeforeUnloadHandler() { - // 使用 beforeunload 事件确保数据保存 - window.addEventListener('beforeunload', async (event) => { - try { - console.log('[Storage] 页面即将关闭,确保数据持久化'); - - // 同步保存所有待写入的数据 - await this.ensureDataPersisted({ skipReady: true }); - - console.log('[Storage] 数据持久化完成'); - } catch (error) { - console.error('[Storage] beforeunload 数据持久化失败:', error); - } - }); - - console.log('[Storage] beforeunload 处理器已设置'); - } - - /** - * 检测数据冲突 - */ - detectVocabListConflict(list1, list2) { - if (!list1 || !list2) return false; - - // 检查是否有相同单词但不同内容 - const conflicts = []; - - const map1 = new Map(list1.words.map(w => [w.word.toLowerCase(), w])); - const map2 = new Map(list2.words.map(w => [w.word.toLowerCase(), w])); - - for (const [word, data1] of map1) { - const data2 = map2.get(word); - if (data2 && data1.timestamp !== data2.timestamp) { - conflicts.push({ - word, - data1, - data2, - resolution: data1.timestamp > data2.timestamp ? 'use_list1' : 'use_list2' - }); - } - } - - return conflicts.length > 0 ? conflicts : false; - } - - /** - * 解决词表冲突 - */ - resolveVocabListConflict(list1, list2, strategy = 'latest') { - if (strategy === 'latest') { - return this.mergeVocabLists(list1, list2); - } else if (strategy === 'keep_list1') { - return list1; - } else if (strategy === 'keep_list2') { - return list2; - } - - return this.mergeVocabLists(list1, list2); - } - - // ==================== 降级存储方案 ==================== - - /** - * 检测 IndexedDB 可用性 - */ - isIndexedDBAvailable() { - try { - // 检查浏览器是否支持 IndexedDB - if (!window.indexedDB) { - console.log('[Storage] IndexedDB 不支持'); - return false; - } - - // 检查是否已成功初始化 - if (this.indexedDB) { - console.log('[Storage] IndexedDB 可用'); - return true; - } - - console.log('[Storage] IndexedDB 未初始化'); - return false; - } catch (error) { - console.error('[Storage] IndexedDB 可用性检测失败:', error); - return false; - } - } - - /** - * 检测 localStorage 可用性 - */ - isLocalStorageAvailable() { - try { - const testKey = '__storage_test__'; - localStorage.setItem(testKey, 'test'); - localStorage.removeItem(testKey); - console.log('[Storage] localStorage 可用'); - return true; - } catch (error) { - console.error('[Storage] localStorage 不可用:', error); - return false; - } - } - - /** - * 获取当前存储类型 - */ - getCurrentStorageType() { - if (this.fallbackStorage) { - return 'memory'; - } else if (this.indexedDB) { - return 'indexedDB'; - } else if (this.isLocalStorageAvailable()) { - return 'localStorage'; - } - return 'none'; - } - - /** - * 处理存储空间不足 - */ - async handleStorageQuotaExceeded(key, value, options = {}) { - console.warn('[Storage] 存储空间不足,尝试清理'); - - try { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - console.error(`[Storage] ${key} 空间不足时禁止 raw fallback`); - if (window.showMessage) { - window.showMessage('练习数据保存空间不足,请先导出备份并清理空间', 'error'); - } - return false; - } - - // 1. 清理旧数据 - await this.cleanupOldData({ skipReady: true }); - - // 2. 再次尝试保存 - const retrySuccess = await this.set(key, value, { skipReady: true }); - if (retrySuccess) { - console.log('[Storage] 清理后保存成功'); - return true; - } - - // 3. 如果仍然失败,尝试降级存储 - console.warn('[Storage] 清理后仍然失败,尝试降级存储'); - - const storageType = this.getCurrentStorageType(); - - if (storageType === 'indexedDB') { - // 降级到 localStorage - console.log('[Storage] 从 IndexedDB 降级到 localStorage'); - try { - const serializedValue = JSON.stringify({ - data: value, - timestamp: Date.now(), - version: this.version - }); - localStorage.setItem(this.getKey(key), serializedValue); - console.log('[Storage] localStorage 保存成功'); - return true; - } catch (localStorageError) { - console.error('[Storage] localStorage 保存失败:', localStorageError); - } - } - - // 4. 最后降级到内存存储 - console.warn('[Storage] 降级到内存存储'); - if (!this.fallbackStorage) { - this.fallbackStorage = new Map(); - } - const serializedValue = JSON.stringify({ - data: value, - timestamp: Date.now(), - version: this.version - }); - this.fallbackStorage.set(this.getKey(key), serializedValue); - - // 提示用户 - if (window.showMessage) { - window.showMessage('存储空间不足,数据已保存到临时存储,请导出备份', 'warning'); - } - - return true; - } catch (error) { - console.error('[Storage] 处理存储空间不足失败:', error); - - // 最终失败,提示用户 - if (window.showMessage) { - window.showMessage('存储空间严重不足,无法保存数据,请清理旧数据', 'error'); - } - - return false; - } - } - - /** - * 词表专用降级保存 - */ - async saveVocabListWithFallback(vocabList, options = {}) { - const { skipReady = false } = options; - - try { - // 首先尝试正常保存 - const success = await this.saveVocabList(vocabList, { skipReady }); - - if (success) { - return true; - } - - // 如果失败,尝试降级保存 - console.warn('[Storage] 词表保存失败,尝试降级保存'); - - // 压缩词表数据 - const compressedList = this.compressVocabList(vocabList); - - // 再次尝试保存压缩后的数据 - const compressedSuccess = await this.saveVocabList(compressedList, { skipReady }); - - if (compressedSuccess) { - console.log('[Storage] 压缩后保存成功'); - return true; - } - - // 如果仍然失败,使用降级存储 - return await this.handleStorageQuotaExceeded( - this.getVocabStorageKey(vocabList.id), - compressedList - ); - } catch (error) { - console.error('[Storage] 词表降级保存失败:', error); - return false; - } - } - - /** - * 压缩词表数据 - */ - compressVocabList(vocabList) { - return { - id: vocabList.id, - name: vocabList.name, - source: vocabList.source, - words: vocabList.words.map(word => ({ - word: word.word, - userInput: word.userInput, - timestamp: word.timestamp, - errorCount: word.errorCount - // 移除其他非必要字段 - })), - createdAt: vocabList.createdAt, - updatedAt: vocabList.updatedAt - }; - } - - /** - * 获取词表存储键 - */ - getVocabStorageKey(listId) { - const keys = this.getVocabStorageKeys(); - - if (listId === 'spelling-errors-p1') return keys.P1_ERRORS; - if (listId === 'spelling-errors-p4') return keys.P4_ERRORS; - if (listId === 'spelling-errors-master') return keys.MASTER_ERRORS; - if (listId === 'custom') return keys.CUSTOM; - - return listId; - } - - /** - * 检查存储健康状态 - */ - async checkStorageHealth(options = {}) { - const { skipReady = false } = options; - - try { - const health = { - indexedDB: this.isIndexedDBAvailable(), - localStorage: this.isLocalStorageAvailable(), - currentType: this.getCurrentStorageType(), - quotaStatus: 'unknown' - }; - - // 检查配额状态 - const storageInfo = await this.getStorageInfo({ skipReady }); - if (storageInfo) { - const usagePercent = storageInfo.type === 'localStorage' - ? (storageInfo.used / (5 * 1024 * 1024)) * 100 - : (storageInfo.used / (105 * 1024 * 1024)) * 100; - - if (usagePercent < 70) { - health.quotaStatus = 'healthy'; - } else if (usagePercent < 90) { - health.quotaStatus = 'warning'; - } else { - health.quotaStatus = 'critical'; - } - - health.usagePercent = usagePercent; - health.used = storageInfo.used; - } - - console.log('[Storage] 存储健康状态:', health); - return health; - } catch (error) { - console.error('[Storage] 检查存储健康状态失败:', error); - return { - indexedDB: false, - localStorage: false, - currentType: 'none', - quotaStatus: 'error' - }; - } - } - - // ==================== 数据导出功能 ==================== - - /** - * 导出练习记录 - */ - async exportPracticeRecords(options = {}) { - const { skipReady = false, format = 'json' } = options; - - try { - console.log('[Storage] 开始导出练习记录'); - - const records = await this.listPracticeRecordsCanonical({ skipReady }); - - const exportData = { - type: 'practice_records', - version: this.version, - exportDate: new Date().toISOString(), - recordCount: records.length, - records: records - }; - - console.log(`[Storage] 练习记录导出完成,共 ${records.length} 条`); - - if (format === 'json') { - return JSON.stringify(exportData, null, 2); - } - - return exportData; - } catch (error) { - console.error('[Storage] 导出练习记录失败:', error); - return null; - } - } - - /** - * 导出词表数据 - */ - async exportVocabLists(options = {}) { - const { skipReady = false, format = 'json', listIds = null } = options; - - try { - console.log('[Storage] 开始导出词表数据'); - - const vocabLists = []; - const targetListIds = listIds || [ - 'spelling-errors-p1', - 'spelling-errors-p4', - 'spelling-errors-master', - 'custom' - ]; - - for (const listId of targetListIds) { - const list = await this.loadVocabList(listId, { skipReady }); - if (list && list.words.length > 0) { - vocabLists.push(list); - } - } - - const exportData = { - type: 'vocabulary_lists', - version: this.version, - exportDate: new Date().toISOString(), - listCount: vocabLists.length, - totalWords: vocabLists.reduce((sum, list) => sum + list.words.length, 0), - lists: vocabLists - }; - - console.log(`[Storage] 词表导出完成,共 ${vocabLists.length} 个词表,${exportData.totalWords} 个单词`); - - if (format === 'json') { - return JSON.stringify(exportData, null, 2); - } - - return exportData; - } catch (error) { - console.error('[Storage] 导出词表数据失败:', error); - return null; - } - } - - /** - * 导出单个词表 - */ - async exportSingleVocabList(listId, options = {}) { - const { skipReady = false, format = 'json' } = options; - - try { - console.log(`[Storage] 开始导出词表: ${listId}`); - - const list = await this.loadVocabList(listId, { skipReady }); - - if (!list) { - console.warn(`[Storage] 词表不存在: ${listId}`); - return null; - } - - const exportData = { - type: 'vocabulary_list', - version: this.version, - exportDate: new Date().toISOString(), - list: list - }; - - console.log(`[Storage] 词表导出完成: ${listId}, ${list.words.length} 个单词`); - - if (format === 'json') { - return JSON.stringify(exportData, null, 2); - } - - return exportData; - } catch (error) { - console.error('[Storage] 导出词表失败:', error); - return null; - } - } - - /** - * 导出完整数据(包括练习记录和词表) - */ - async exportCompleteData(options = {}) { - const { skipReady = false, format = 'json' } = options; - - try { - console.log('[Storage] 开始导出完整数据'); - - // 导出所有数据 - const allData = await this.exportData({ skipReady }); - - // 导出练习记录 - const practiceRecords = await this.exportPracticeRecords({ - skipReady, - format: 'object' - }); - - // 导出词表 - const vocabLists = await this.exportVocabLists({ - skipReady, - format: 'object' - }); - - const exportData = { - type: 'complete_export', - version: this.version, - exportDate: new Date().toISOString(), - summary: { - totalRecords: allData?.storageInfo?.totalRecords || 0, - practiceRecords: practiceRecords?.recordCount || 0, - vocabLists: vocabLists?.listCount || 0, - totalWords: vocabLists?.totalWords || 0 - }, - data: { - all: allData, - practiceRecords: practiceRecords, - vocabLists: vocabLists - } - }; - - console.log('[Storage] 完整数据导出完成'); - - if (format === 'json') { - return JSON.stringify(exportData, null, 2); - } - - return exportData; - } catch (error) { - console.error('[Storage] 导出完整数据失败:', error); - return null; - } - } - - /** - * 下载导出数据为文件 - */ - downloadExportData(data, filename = null) { - try { - if (!data) { - console.error('[Storage] 无数据可导出'); - return false; - } - - // 确保数据是字符串格式 - const jsonString = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - - // 创建 Blob - const blob = new Blob([jsonString], { type: 'application/json' }); - - // 生成文件名 - const defaultFilename = `ielts-practice-export-${new Date().toISOString().split('T')[0]}.json`; - const finalFilename = filename || defaultFilename; - - // 创建下载链接 - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = finalFilename; - - // 触发下载 - document.body.appendChild(link); - link.click(); - - // 清理 - document.body.removeChild(link); - URL.revokeObjectURL(url); - - console.log(`[Storage] 数据已下载: ${finalFilename}`); - return true; - } catch (error) { - console.error('[Storage] 下载导出数据失败:', error); - return false; - } - } - - /** - * 导出并下载练习记录 - */ - async exportAndDownloadPracticeRecords(filename = null) { - try { - const data = await this.exportPracticeRecords({ format: 'json' }); - if (data) { - const defaultFilename = `practice-records-${new Date().toISOString().split('T')[0]}.json`; - return this.downloadExportData(data, filename || defaultFilename); - } - return false; - } catch (error) { - console.error('[Storage] 导出并下载练习记录失败:', error); - return false; - } - } - - /** - * 导出并下载词表数据 - */ - async exportAndDownloadVocabLists(filename = null) { - try { - const data = await this.exportVocabLists({ format: 'json' }); - if (data) { - const defaultFilename = `vocab-lists-${new Date().toISOString().split('T')[0]}.json`; - return this.downloadExportData(data, filename || defaultFilename); - } - return false; - } catch (error) { - console.error('[Storage] 导出并下载词表数据失败:', error); - return false; - } - } - - /** - * 导出并下载完整数据 - */ - async exportAndDownloadCompleteData(filename = null) { - try { - const data = await this.exportCompleteData({ format: 'json' }); - if (data) { - const defaultFilename = `complete-data-${new Date().toISOString().split('T')[0]}.json`; - return this.downloadExportData(data, filename || defaultFilename); - } - return false; - } catch (error) { - console.error('[Storage] 导出并下载完整数据失败:', error); - return false; - } - } - - /** - * 导入词表数据 - */ - async importVocabLists(importData, options = {}) { - const { skipReady = false, merge = true } = options; - - try { - console.log('[Storage] 开始导入词表数据'); - - if (!importData || !importData.lists) { - console.error('[Storage] 导入数据格式无效'); - return false; - } - - let successCount = 0; - let failCount = 0; - - for (const list of importData.lists) { - try { - if (merge) { - // 合并模式:与现有数据合并 - const success = await this.syncVocabList(list.id, list, { skipReady }); - if (success) { - successCount++; - } else { - failCount++; - } - } else { - // 覆盖模式:直接保存 - const success = await this.saveVocabList(list, { skipReady }); - if (success) { - successCount++; - } else { - failCount++; - } - } - } catch (error) { - console.error(`[Storage] 导入词表失败: ${list.id}`, error); - failCount++; - } - } - - console.log(`[Storage] 词表导入完成: ${successCount} 成功, ${failCount} 失败`); - return { successCount, failCount }; - } catch (error) { - console.error('[Storage] 导入词表数据失败:', error); - return false; - } - } -} - -const STORAGE_SYNC_IGNORED_KEYS = new Set([ - 'namespace_test', - 'namespace_test_practice', - 'namespace_test_enhancer' -]); - -StorageManager.prototype.dispatchStorageSync = function(key) { - try { - const normalizedKey = typeof key === 'string' ? key.replace(this.prefix, '') : key; - if (normalizedKey && STORAGE_SYNC_IGNORED_KEYS.has(normalizedKey)) { - return; - } - } catch (_) { - // ignore errors resolving key - } - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } })); -}; - -// 创建全局存储实例 -class PreferenceStore { - constructor(prefix = 'exam_system_') { - this.prefix = prefix; - this.ready = Promise.resolve(); - } - - setNamespace(namespace) { - if (typeof namespace === 'string' && namespace.trim()) { - this.prefix = namespace.trim() + '_'; - } - } - - getScopedKey(key) { - return key.startsWith(this.prefix) ? key : this.prefix + key; - } - - getStorageArea(session = false) { - return session ? window.sessionStorage : window.localStorage; - } - - serialize(value) { - return JSON.stringify({ data: value, timestamp: Date.now() }); - } - - deserialize(rawValue, defaultValue = null) { - if (!rawValue) { - return defaultValue; - } - try { - const parsed = JSON.parse(rawValue); - return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data') - ? parsed.data - : defaultValue; - } catch (_) { - return defaultValue; - } - } - - async get(key, defaultValue = null, options = {}) { - const storage = this.getStorageArea(options.session === true); - return this.deserialize(storage.getItem(this.getScopedKey(key)), defaultValue); - } - - async set(key, value, options = {}) { - const storage = this.getStorageArea(options.session === true); - storage.setItem(this.getScopedKey(key), this.serialize(value)); - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } })); - return true; - } - - async remove(key, options = {}) { - const storage = this.getStorageArea(options.session === true); - storage.removeItem(this.getScopedKey(key)); - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } })); - return true; - } - - async clear(options = {}) { - const storage = this.getStorageArea(options.session === true); - Object.keys(storage) - .filter((key) => key.startsWith(this.prefix)) - .forEach((key) => storage.removeItem(key)); - return true; - } -} - -class StorageKeyRegistry { - constructor() { - this.preferenceKeys = new Set([ - 'theme_settings', - 'current_theme', - 'keyboard_shortcuts_enabled', - 'sound_effects_enabled', - 'auto_save_enabled', - 'notifications_enabled', - 'theme', - 'bloom-theme-mode', - 'blue-theme-mode', - 'browse_state', - 'hasSeenGplLicense', - 'preferred_theme_portal' - ]); - this.sessionKeys = new Set([ - 'preferred_theme_skip_session' - ]); - } - - resolve(key) { - if (this.sessionKeys.has(key)) { - return { key, storageClass: 'session' }; - } - if (this.preferenceKeys.has(key)) { - return { key, storageClass: 'preference' }; - } - return { key, storageClass: 'persistent' }; - } -} - -class StorageFacade { - constructor(options = {}) { - this.persistentStore = options.persistentStore; - this.preferenceStore = options.preferenceStore; - this.keyRegistry = options.keyRegistry; - this.ready = this.persistentStore ? this.persistentStore.ready : Promise.resolve(); - } - - setNamespace(namespace) { - if (this.persistentStore && typeof this.persistentStore.setNamespace === 'function') { - this.persistentStore.setNamespace(namespace); - } - if (this.preferenceStore && typeof this.preferenceStore.setNamespace === 'function') { - this.preferenceStore.setNamespace(namespace); - } - } - - resolveStore(key) { - const entry = this.keyRegistry.resolve(key); - if (entry.storageClass === 'preference') { - return { entry, store: this.preferenceStore, options: { session: false } }; - } - if (entry.storageClass === 'session') { - return { entry, store: this.preferenceStore, options: { session: true } }; - } - return { entry, store: this.persistentStore, options: {} }; - } - - async get(key, defaultValue = null, options = {}) { - const target = this.resolveStore(key); - return await target.store.get(key, defaultValue, Object.assign({}, target.options, options)); - } - - async set(key, value, options = {}) { - const target = this.resolveStore(key); - return await target.store.set(key, value, Object.assign({}, target.options, options)); - } - - async remove(key, options = {}) { - const target = this.resolveStore(key); - return await target.store.remove(key, Object.assign({}, target.options, options)); - } - - async clear(options = {}) { - if (this.persistentStore && typeof this.persistentStore.clear === 'function') { - await this.persistentStore.clear(options); - } - if (this.preferenceStore && typeof this.preferenceStore.clear === 'function') { - await this.preferenceStore.clear({ session: false }); - await this.preferenceStore.clear({ session: true }); - } - return true; - } - - async getStorageInfo(options = {}) { - const persistentInfo = this.persistentStore && typeof this.persistentStore.getStorageInfo === 'function' - ? await this.persistentStore.getStorageInfo(options) - : null; - return Object.assign({}, persistentInfo || {}, { - facade: 'storage-facade', - volatile: Boolean(this.persistentStore && this.persistentStore.volatileMode) - }); - } -} - -const storageManager = new StorageManager(); -const preferenceStore = new PreferenceStore(storageManager.prefix); -const storageKeyRegistry = new StorageKeyRegistry(); -const storageFacade = new StorageFacade({ - persistentStore: storageManager, - preferenceStore, - keyRegistry: storageKeyRegistry -}); - -window.persistentStore = storageManager; -window.preferenceStore = preferenceStore; -window.storageKeyRegistry = storageKeyRegistry; -window.storage = storageFacade; -Object.defineProperty(window, '__installStorageInternalAccess', { - value(install) { - if (typeof install !== 'function') { - throw new Error('__installStorageInternalAccess requires an installer function'); - } - const result = install(createInternalAccessOptions, hasInternalAccessOptions); - if (result !== false) { - try { - delete window.__installStorageInternalAccess; - } catch (_) { - window.__installStorageInternalAccess = undefined; - } - } - return result; - }, - enumerable: false, - configurable: true, - writable: false -}); - -// 启动存储监控和数据同步 -storageManager.ready - .then(() => { - storageManager.startStorageMonitoring(); - storageManager.setupBeforeUnloadHandler(); - }) - .catch(error => { - console.error('[Storage] 存储初始化失败,监控未启动:', error); - }); -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/utils/vocabDataIO.js b/js/utils/vocabDataIO.js index e009a2a8..79a81e16 100644 --- a/js/utils/vocabDataIO.js +++ b/js/utils/vocabDataIO.js @@ -235,10 +235,12 @@ return buildImportResult('progress', entries, { format: 'json', originalLength: payload.words.length, + listId: typeof payload.listId === 'string' && payload.listId.trim() + ? payload.listId.trim() + : undefined, category: category || 'user', version: typeof payload.version === 'string' ? payload.version : undefined, config: payload.config && typeof payload.config === 'object' ? { ...payload.config } : undefined, - reviewQueue: Array.isArray(payload.reviewQueue) ? payload.reviewQueue.slice() : undefined, name: typeof payload.name === 'string' ? payload.name : undefined, source: typeof payload.source === 'string' ? payload.source : undefined, exportedAt: typeof payload.exportedAt === 'string' ? payload.exportedAt : undefined @@ -309,17 +311,17 @@ } async function exportProgress() { - const store = window.VocabStore; - if (!store || typeof store.init !== 'function') { - throw new Error('VocabStore 未加载'); - } - await store.init(); + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab 未加载'); + await window.AppData.ready; + const config = await window.AppData.vocab.getConfig(); + const listId = config.activeListId || 'default'; + const list = await window.AppData.vocab.readList(listId); const payload = { version: DEFAULT_EXPORT_VERSION, exportedAt: new Date().toISOString(), - config: store.getConfig(), - words: store.getWords(), - reviewQueue: store.getReviewQueue() + listId, + config, + words: Array.isArray(list) ? list : (list && Array.isArray(list.words) ? list.words : []) }; return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); } From e60774f9e5f113e22bc90cabd68187e02fd49e05 Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:33:53 +0800 Subject: [PATCH 09/18] feat(practice): canonicalize records, recovery, and suite state --- js/app/examSessionMixin.js | 1749 ++++++++++++++++++---- js/app/state-service.js | 93 -- js/app/suitePracticeMixin.js | 433 ++++-- js/components/practiceHistoryEnhancer.js | 56 +- js/components/practiceRecordModal.js | 78 +- js/core/practiceCore.js | 545 +------ js/core/practiceRecorder.js | 695 +++++---- js/practice-page-enhancer.js | 235 ++- js/utils/practiceTimerPreferences.js | 49 +- js/utils/suitePreference.js | 87 +- 10 files changed, 2410 insertions(+), 1610 deletions(-) diff --git a/js/app/examSessionMixin.js b/js/app/examSessionMixin.js index 803ae383..58ba228f 100644 --- a/js/app/examSessionMixin.js +++ b/js/app/examSessionMixin.js @@ -6,59 +6,11 @@ const PRACTICE_ENHANCER_BUILD_ID = '20250105'; async function getActiveExamIndexSnapshot() { - const stateGetters = [ - () => (typeof global.getExamIndexState === 'function') ? global.getExamIndexState() : null, - () => (typeof getExamIndexState === 'function') ? getExamIndexState : null - ]; - - for (const getterFactory of stateGetters) { - try { - const getter = getterFactory(); - if (typeof getter === 'function') { - const state = getter(); - if (Array.isArray(state) && state.length) { - return state.slice(); - } - } - } catch (_) { } - } - - let activeKey = 'exam_index'; - try { - if (typeof global.getActiveLibraryConfigurationKey === 'function') { - const resolved = await global.getActiveLibraryConfigurationKey(); - if (resolved && typeof resolved === 'string' && resolved.trim()) { - activeKey = resolved.trim(); - } - } else { - const storedKey = await storage.get('active_exam_index_key', 'exam_index'); - if (storedKey && typeof storedKey === 'string' && storedKey.trim()) { - activeKey = storedKey.trim(); - } - } - } catch (_) { - try { - const storedKey = await storage.get('active_exam_index_key', 'exam_index'); - if (storedKey && typeof storedKey === 'string' && storedKey.trim()) { - activeKey = storedKey.trim(); - } - } catch (_) { } - } - - let dataset = await storage.get(activeKey, []) || []; - if ((!Array.isArray(dataset) || dataset.length === 0) && activeKey !== 'exam_index') { - dataset = await storage.get('exam_index', []) || []; + if (typeof global.resolveActiveLibraryIndex !== 'function') { + throw new Error('LibraryManager.resolveActiveIndex is unavailable'); } - if (!Array.isArray(dataset) || dataset.length === 0) { - if (Array.isArray(global.examIndex) && global.examIndex.length) { - dataset = global.examIndex.slice(); - } else if (typeof global.getReadingExamIndex === 'function') { - dataset = global.getReadingExamIndex(); - } else if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) { - dataset = global.__READING_EXAM_INDEX__.slice(); - } - } - return Array.isArray(dataset) ? dataset : []; + const dataset = await global.resolveActiveLibraryIndex(); + return Array.isArray(dataset) ? dataset.slice() : []; } async function findExamDefinition(examId) { @@ -71,20 +23,6 @@ return match; } - const fallbacks = [ - Array.isArray(global.examIndex) ? global.examIndex : null, - typeof global.getReadingExamIndex === 'function' ? global.getReadingExamIndex() : null, - Array.isArray(global.__READING_EXAM_INDEX__) ? global.__READING_EXAM_INDEX__ : null, - Array.isArray(global.listeningExamIndex) ? global.listeningExamIndex : null - ]; - for (const fallback of fallbacks) { - if (!Array.isArray(fallback)) continue; - const found = fallback.find(entry => entry && entry.id === examId); - if (found) { - return found; - } - } - return null; } @@ -368,10 +306,18 @@ * 打开指定题目进行练习 */ async openExam(examId, options = {}) { - const examIndex = await getActiveExamIndexSnapshot(); - const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []); - const exam = list.find(e => e.id === examId); const reviewMode = Boolean(options && options.reviewMode); + let exam = options && options.examDefinition && typeof options.examDefinition === 'object' + ? options.examDefinition + : null; + if (!exam) { + if (options && options.requireRecordProvenance) { + throw new Error('历史记录的题库来源不可用'); + } + const examIndex = await getActiveExamIndexSnapshot(); + const list = Array.isArray(examIndex) ? examIndex : []; + exam = list.find(e => e.id === examId); + } const practiceMode = options && typeof options.practiceMode === 'string' ? options.practiceMode.trim().toLowerCase() : ''; @@ -414,6 +360,9 @@ if (guardOptions.suiteSessionId && readingLaunch && readingLaunch.mode === 'unified_html') { examUrl = this._appendSuiteContextToExamUrl(examUrl, guardOptions); } + if (guardOptions.endlessMode) { + examUrl = this._appendEndlessContextToExamUrl(examUrl); + } let examWindow = this.openExamWindow(examUrl, exam, guardOptions); try { @@ -428,12 +377,27 @@ await this._cleanupReusedWindowSessions(examWindow, examId); } - // 再进行会话记录与脚本注入 + // 在启动窗口前捕获激活的题库配置 ID,确保后续练习记录 metadata 来源 + // 一律按"启动时"的题库写入,避免用户在考试过程中切换题库导致提交时来源不一致。 + if (!reviewMode) { + try { + await this._captureLaunchLibraryConfigurationId(examId); + } catch (captureError) { + console.warn('[App] 捕获启动题库配置 ID 失败:', captureError); + } + } + + // Register the window first so the host expectedSessionId exists, then start the + // recorder with that same id. Starting the recorder before window setup used + // to mint a second session id that never matched INIT/COMPLETE. + this.setupExamWindowManagement(examWindow, examId, exam, { + ...options, + expectedUrl: this._ensureAbsoluteUrl(examUrl) + }); if (!reviewMode && !memorizeMode) { await this.startPracticeSession(examId); } this.injectDataCollectionScript(examWindow, examId, exam); - this.setupExamWindowManagement(examWindow, examId, exam, options); if (options && options.suiteSessionId) { const sessionInfo = this.ensureExamWindowSession(examId, examWindow); @@ -634,6 +598,87 @@ } }, + _resolveExamMessageEndpoint(rawUrl) { + const href = this._ensureAbsoluteUrl(rawUrl); + if (!href) { + return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false }; + } + try { + const parsed = new URL(href, window.location.href); + // Chromium reports URL.origin as "file://" while postMessage events + // between file pages use the opaque origin "null". + if (parsed.protocol === 'file:') { + return { + expectedUrl: parsed.href, + expectedOrigin: 'null', + allowOpaqueOrigin: true + }; + } + if (parsed.origin && parsed.origin !== 'null') { + return { + expectedUrl: parsed.href, + expectedOrigin: parsed.origin, + allowOpaqueOrigin: false + }; + } + } catch (_) { + // An unparseable launch URL must never degrade to wildcard messaging. + } + return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false }; + }, + + _reportExamMessageRejected(examId, type, reason, event = null) { + if (!this._examMessageRejectionCounts) this._examMessageRejectionCounts = new Map(); + const key = `${String(reason || 'unknown')}:${String(type || 'unknown')}`; + const count = Number(this._examMessageRejectionCounts.get(key) || 0) + 1; + this._examMessageRejectionCounts.set(key, count); + const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + const originClass = incomingOrigin === 'null' + ? 'opaque' + : (incomingOrigin && window.location && incomingOrigin === window.location.origin ? 'same-origin' : (incomingOrigin ? 'cross-origin' : 'missing')); + const detail = { + reason: String(reason || 'unknown'), + messageType: String(type || 'unknown'), + examId: String(examId || ''), + originClass, + count + }; + if (count === 1 || count % 10 === 0) { + console.debug('[ExamMessage] rejected', detail); + } + try { + window.dispatchEvent(new CustomEvent('ielts-atlas:message-rejected', { detail })); + } catch (_) { + // Telemetry must never affect the security decision. + } + return false; + }, + + _postExamMessage(examId, targetWindow, type, data = {}) { + if (!targetWindow || targetWindow.closed || typeof targetWindow.postMessage !== 'function') { + return false; + } + const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + const targetOrigin = windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null' + ? windowInfo.expectedOrigin + : (windowInfo.allowOpaqueOrigin ? '*' : ''); + if (!targetOrigin) { + console.warn('[App] 拒绝向未绑定可信 origin 的题目窗口发送消息:', type, examId); + return false; + } + const payload = Object.assign({}, data || {}, { + examId: data && data.examId != null ? data.examId : examId, + windowSessionToken: windowInfo.windowSessionToken + }); + targetWindow.postMessage({ + type, + data: payload, + source: 'exam_host', + timestamp: Date.now() + }, targetOrigin); + return true; + }, + _appendSuiteContextToExamUrl(rawUrl, options = {}) { if (!rawUrl) { return rawUrl; @@ -671,6 +716,19 @@ } }, + _appendEndlessContextToExamUrl(rawUrl) { + if (!rawUrl) { + return rawUrl; + } + try { + const parsed = new URL(rawUrl, (window && window.location && window.location.href) ? window.location.href : undefined); + parsed.searchParams.set('endless', '1'); + return parsed.toString(); + } catch (_) { + return rawUrl; + } + }, + _normalizeSuiteTimerAnchor(value) { if (value == null || value === '') { return null; @@ -835,6 +893,14 @@ if (!examWindow || examWindow.closed) { return examWindow; } + // Separate file:// documents have opaque origins. Reading a child + // window's location is forbidden even when both files are local, + // and the launch URL has already been resolved by openExam(). + if (typeof window !== 'undefined' + && window.location + && window.location.protocol === 'file:') { + return examWindow; + } const resolveHref = (targetWindow) => { try { @@ -962,6 +1028,7 @@ _buildExamPlaceholderUrl(exam = null, options = {}) { const basePath = 'templates/exam-placeholder.html'; const params = new URLSearchParams(); + params.set('suite_test', '1'); const safeSet = (key, value) => { if (value == null) { @@ -1093,6 +1160,11 @@ return; } + if (isListeningExam && doc.documentElement + && doc.documentElement.dataset.listeningWrapper === 'true') { + return; + } + // 套题占位页自带消息协议与按钮,不需要再注入增强器(避免重复发送 PRACTICE_COMPLETE) try { if (doc.getElementById('complete-exam-btn') && doc.getElementById('force-ready-btn')) { @@ -1187,6 +1259,8 @@ } const sessionToken = `${examId}_${Date.now()}`; + // 备用方案注入时同步读取 host 端启动时捕获的题库配置 ID,确保 enhancer 也能拿到来源。 + const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId); const inlineScript = examWindow.document.createElement('script'); inlineScript.type = 'text/javascript'; inlineScript.textContent = ` @@ -1202,7 +1276,26 @@ examId: ${JSON.stringify(examId)}, startTime: Date.now(), answers: {}, - suite: { + // 启动时 host 端捕获的题库配置 ID;每条 INIT_SESSION 还会再次以 + // initData.libraryConfigurationId 同步更新,确保即使延迟加载也能拿到正确来源。 + libraryConfigurationId: ${JSON.stringify(launchLibraryConfigurationId || null)}, + expectedParentOrigin: (function() { + try { + if (!document.referrer) return ''; + var parsed = new URL(document.referrer, window.location.href); + // Chromium: file URL.origin is "file://", postMessage event.origin is "null". + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (_) { + return ''; + } + })(), + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + submissionId: '', + suite: { active: false, sessionId: null, guarded: false, @@ -1211,12 +1304,40 @@ } }; + function createSubmissionId() { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return 'inline-submit-' + window.crypto.randomUUID(); + } + } catch (_) {} + return 'inline-submit-' + (state.sessionId || state.examId || 'session') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2); + } + function sendMessage(type, data) { if (!parentWindow || typeof parentWindow.postMessage !== 'function') { return; } try { - parentWindow.postMessage({ type: type, data: data || {} }, '*'); + var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) return; + var payload = Object.assign({}, data || {}); + if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') { + if (!state.submissionId) { + state.submissionId = payload.submissionId || createSubmissionId(); + } + payload.sessionId = payload.sessionId || state.sessionId || null; + payload.submissionId = payload.submissionId || state.submissionId; + } + parentWindow.postMessage({ + type: type, + data: Object.assign(payload, { + windowSessionToken: state.windowSessionToken || null + }), + source: 'inline_collector', + timestamp: Date.now() + }, targetOrigin); } catch (error) { console.warn('[InlineEnhancer] 无法发送消息:', error); } @@ -1333,11 +1454,21 @@ function handleInitSession(message) { var initData = message && message.data ? message.data : {}; if (initData.sessionId) { + if (state.sessionId && String(state.sessionId) !== String(initData.sessionId)) { + state.submissionId = ''; + } state.sessionId = initData.sessionId; } if (initData.examId) { state.examId = initData.examId; } + // host 启动时捕获并随 INIT_SESSION 携带的题库配置 ID;这里同步更新 state, + // 在 enhancer 回传完成结果时一并透传,避免后续提交再读当前激活题库。 + if (typeof initData.libraryConfigurationId !== 'undefined' + && initData.libraryConfigurationId !== null + && initData.libraryConfigurationId !== '') { + state.libraryConfigurationId = initData.libraryConfigurationId; + } if (initData.suiteSessionId) { state.suite.active = true; state.suite.sessionId = initData.suiteSessionId; @@ -1359,10 +1490,55 @@ } if (message.type === 'INIT_SESSION') { + var initData = message.data || {}; + var incomingOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + var declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : ''; + var incomingToken = typeof initData.windowSessionToken === 'string' + ? initData.windowSessionToken.trim() + : ''; + if (!event || event.source !== parentWindow || message.source !== 'exam_host' || !incomingToken) return; + var expectedParentOrigin = state.expectedParentOrigin + && state.expectedParentOrigin !== 'file://' + && String(state.expectedParentOrigin).indexOf('file:') !== 0 + ? state.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return; + state.parentOrigin = expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else if (window.location.protocol === 'file:') { + var trustedFileOrigin = incomingOrigin === 'null' + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) return; + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } else { + var trustedWebOrigin = !!incomingOrigin + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) return; + state.parentOrigin = incomingOrigin; + state.parentOriginIsOpaque = false; + } + state.windowSessionToken = incomingToken; handleInitSession(message); return; } + var messageData = message.data || {}; + var messageToken = typeof messageData.windowSessionToken === 'string' + ? messageData.windowSessionToken.trim() + : ''; + var messageOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + var originMatches = state.parentOriginIsOpaque + ? messageOrigin === 'null' + : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin); + if (!event || event.source !== parentWindow || message.source !== 'exam_host' + || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) { + return; + } + if (!state.suite.active) { return; } @@ -1424,7 +1600,9 @@ examId: state.examId, duration: Math.round((Date.now() - state.startTime) / 1000), answers: state.answers, - source: 'inline_collector' + source: 'inline_collector', + // 透传启动时捕获的题库配置 ID,便于 host 端 completeAttempt 写入 metadata 来源。 + libraryConfigurationId: state.libraryConfigurationId || null }); } }; @@ -1502,10 +1680,7 @@ const initPayload = this._buildExamInitPayload(examId, windowInfo, { timestamp: now }); // 发送会话初始化消息 - examWindow.postMessage({ - type: 'INIT_SESSION', - data: initPayload - }, '*'); + this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); // 存储会话信息 if (!this.examWindows) { @@ -1553,13 +1728,7 @@ type: 'script_injection_error' }; - // 保存错误日志到本地存储 - const errorLogs = await storage.get('injection_errors', []); - errorLogs.push(errorInfo); - if (errorLogs.length > 50) { - errorLogs.splice(0, errorLogs.length - 50); // 保留最近50条错误 - } - await storage.set('injection_errors', errorLogs); + console.warn('[DataInjection] 诊断信息:', errorInfo); // 不显示错误给用户,静默处理 console.warn('[DataInjection] 将使用模拟数据模式'); @@ -1588,12 +1757,20 @@ this.examWindows = new Map(); } + const endpoint = this._resolveExamMessageEndpoint( + options && options.expectedUrl + ? options.expectedUrl + : (exam ? this.buildExamUrl(exam) : '') + ); this.examWindows.set(examId, { window: examWindow, startTime: Date.now(), status: 'active', expectedSessionId: null, - origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '', + expectedUrl: endpoint.expectedUrl, + expectedOrigin: endpoint.expectedOrigin, + allowOpaqueOrigin: endpoint.allowOpaqueOrigin, + observedOrigin: '', suiteSessionId: (options && options.suiteSessionId) ? options.suiteSessionId : null, suiteFlowMode: (options && options.suiteFlowMode) ? String(options.suiteFlowMode) : null, suiteSequenceIndex: Number.isInteger(options && options.sequenceIndex) ? options.sequenceIndex : null, @@ -1641,12 +1818,33 @@ console.warn('[App] 启动握手失败:', e); } - const emitInitEnvelope = () => { + const emitInitEnvelope = async () => { const windowInfo = this.ensureExamWindowSession(examId, examWindow); + // 让最早到达的 INIT 即携带 draft,避免无 draft 的 envelope 先被去重守卫登记, + // 从而使后续携带 draft 的 INIT 被当作重复而丢弃、草稿无法恢复。 + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo); try { - examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, examWindow, 'init_exam_session', initPayload); } catch (postError) { console.warn('[App] 跨源初始化题目窗口失败:', postError); } @@ -1713,6 +1911,9 @@ 'SUITE_CONFIG_UPDATE', 'VOCAB_HIGHLIGHT_SAVE', 'SIMULATION_DRAFT_SYNC', + 'READING_DRAFT_SYNC', + 'READING_ANNOTATION_SYNC', + 'PRACTICE_RECORD_SAVED', 'SIMULATION_NAVIGATE', 'SIMULATION_ACTIVE_EXAM_CHANGE', 'SIMULATION_SUBMIT' @@ -1842,29 +2043,44 @@ // 缺少来源窗口直接拒绝 if (!sourceWindow || !expectedWindow) { + this._reportExamMessageRejected(examId, '', 'missing-window', event); return; } - // 校验来源域,允许 file:// (origin 为 null) 与同源页面 - if (event.origin && event.origin !== 'null') { - const allowedOrigin = window.location && window.location.origin; - if (allowedOrigin && event.origin !== allowedOrigin) { - return; - } - } - const normalized = normalizeMessage(event.data); if (!normalized) { + this._reportExamMessageRejected(examId, '', 'invalid-envelope', event); return; } const windowInfo = this.ensureExamWindowSession(examId, expectedWindow); const expectedSessionId = windowInfo.expectedSessionId || ''; + // Most messages must still come from the exact exam window. A small + // suite/listening compatibility path below can prove an equivalent + // source with the window token and full session scope; do not reject + // before those constraints have been evaluated. + const sourceMatched = sourceWindow === expectedWindow; + const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + if (windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null') { + if (incomingOrigin !== windowInfo.expectedOrigin) { + this._reportExamMessageRejected(examId, normalized.type, 'origin-mismatch', event); + return; + } + } else if (windowInfo.allowOpaqueOrigin) { + if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') { + this._reportExamMessageRejected(examId, normalized.type, 'opaque-origin-mismatch', event); + return; + } + } else { + this._reportExamMessageRejected(examId, normalized.type, 'origin-unbound', event); + return; + } // 放宽消息源过滤,兼容 inline_collector 与 practice_page const src = normalized.sourceTag || ''; const allowedSources = new Set(['practice_page', 'inline_collector', 'suite_placeholder', 'listening_record_bridge']); - if (src && !allowedSources.has(src)) { + if (!src || !allowedSources.has(src)) { + this._reportExamMessageRejected(examId, normalized.type, 'source-tag-mismatch', event); return; // 非预期来源的消息忽略 } @@ -1914,6 +2130,16 @@ const expectedWindowSessionToken = windowInfo && typeof windowInfo.windowSessionToken === 'string' ? windowInfo.windowSessionToken.trim() : ''; + const permitsPreInitWithoutToken = type === 'REQUEST_INIT' + || (type === 'SESSION_READY' && data.initialized !== true); + if (!permitsPreInitWithoutToken && ( + !expectedWindowSessionToken + || !payloadWindowSessionToken + || payloadWindowSessionToken !== expectedWindowSessionToken + )) { + this._reportExamMessageRejected(examId, type, 'token-mismatch', event); + return; + } const canRoutePayloadExamInActiveSuite = Boolean( suiteRoutableMessageTypes.has(type) && isPayloadExamInActiveSuite @@ -1921,7 +2147,80 @@ && payloadSuiteSessionId && payloadSuiteSessionId === activeSuiteSessionId ); - const sourceMatched = isLikelySameWindowContext(sourceWindow, expectedWindow); + const isReadingAnnotationSync = type === 'READING_ANNOTATION_SYNC'; + const isReadingDraftSync = type === 'READING_DRAFT_SYNC'; + if (isReadingAnnotationSync) { + const expectedReviewSessionId = windowInfo && windowInfo.reviewSessionId + ? String(windowInfo.reviewSessionId) + : ''; + const payloadReviewSessionId = data && data.reviewSessionId != null + ? String(data.reviewSessionId) + : ''; + const payloadRecordId = data && data.recordId != null ? String(data.recordId) : ''; + const hasStrictSessionBinding = Boolean( + expectedSessionId + && payloadSessionId + && payloadSessionId === expectedSessionId + ); + const hasStrictWindowToken = Boolean( + expectedWindowSessionToken + && payloadWindowSessionToken + && payloadWindowSessionToken === expectedWindowSessionToken + ); + const hasStrictReviewBinding = Boolean( + windowInfo + && windowInfo.reviewMode + && expectedReviewSessionId + && payloadReviewSessionId === expectedReviewSessionId + ); + // 单篇阅读 final-submit 后,结果页以已存档 recordId 发送标注同步: + // 不在 review 回放态,但 windowInfo.submittedRecordId 必须与 payload + // recordId 严格匹配,并仍受 source/会话/窗口 token/题号约束。 + const hasSubmittedRecordBinding = Boolean( + windowInfo + && !windowInfo.reviewMode + && windowInfo.submittedRecordId + && payloadRecordId + && payloadRecordId === String(windowInfo.submittedRecordId) + ); + if ( + !sourceMatched + || !hasStrictSessionBinding + || !hasStrictWindowToken + || (!hasStrictReviewBinding && !hasSubmittedRecordBinding) + || !payloadExamId + || payloadExamId !== expectedExamId + ) { + return; + } + } + if (isReadingDraftSync) { + const hasStrictSessionBinding = Boolean( + expectedSessionId + && payloadSessionId + && payloadSessionId === expectedSessionId + ); + const hasStrictWindowToken = Boolean( + expectedWindowSessionToken + && payloadWindowSessionToken + && payloadWindowSessionToken === expectedWindowSessionToken + ); + const isLivePracticeWindow = Boolean( + windowInfo + && !windowInfo.reviewMode + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + ); + if ( + !sourceMatched + || !hasStrictSessionBinding + || !hasStrictWindowToken + || !isLivePracticeWindow + || !payloadExamId + || payloadExamId !== expectedExamId + ) { + return; + } + } const payloadWindowInfo = payloadExamId && payloadExamId !== expectedExamId && this.examWindows ? this.examWindows.get(payloadExamId) : null; @@ -1970,22 +2269,28 @@ const allowSuiteSourceFallback = Boolean( !sourceMatched && payloadExamId + && payloadSessionId + && expectedSessionId + && payloadSessionId === expectedSessionId && payloadTokenMatchesExpectedWindow && (payloadExamId === expectedExamId || isPayloadExamInActiveSuite) - && ( - (payloadSuiteSessionId && activeSuiteSessionId && payloadSuiteSessionId === activeSuiteSessionId) - || isExamInActiveSuite - ) + && payloadSuiteSessionId + && activeSuiteSessionId + && payloadSuiteSessionId === activeSuiteSessionId ); const allowListeningSourceFallback = Boolean( !sourceMatched && isListeningBridgeProtocolMessage - && ( - (payloadExamId && payloadExamId === expectedExamId) - || (payloadSessionId && expectedSessionId && payloadSessionId === expectedSessionId) - ) + && payloadTokenMatchesExpectedWindow + && payloadExamId + && payloadExamId === expectedExamId + && payloadSessionId + && expectedSessionId + && payloadSessionId === expectedSessionId + && (!payloadSuiteSessionId || !activeSuiteSessionId || payloadSuiteSessionId === activeSuiteSessionId) ); if (!sourceMatched && !allowSuiteSourceFallback && !allowListeningSourceFallback) { + this._reportExamMessageRejected(examId, type, 'window-mismatch', event); return; } if (windowInfo && sourceWindow && (sourceMatched || !expectedWindow || expectedWindow.closed)) { @@ -2068,8 +2373,19 @@ if (!data.sessionId && expectedSessionId) { data.sessionId = expectedSessionId; } + if ( + (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') + && ( + !String(data.submissionId || '').trim() + || !String(data.sessionId || '').trim() + || !String(payloadWindowSessionToken || '').trim() + ) + ) { + this._reportExamMessageRejected(examId, type, 'missing-submission-contract', event); + return; + } - windowInfo.origin = event.origin; + windowInfo.observedOrigin = event.origin; windowInfo.lastMessageAt = Date.now(); windowInfo.lastMessageType = type; if (payloadWindowSessionToken) { @@ -2141,18 +2457,34 @@ window.practiceConfig.suite = {}; } window.practiceConfig.suite.autoAdvanceAfterSubmit = autoAdvance; - try { - if (window.localStorage) { - window.localStorage.setItem('suite_auto_advance_after_submit', String(autoAdvance)); - } - } catch (_) { - // ignore storage write failures - } + await window.AppData.preferences.patchSuite({ autoAdvanceAfterSubmit: autoAdvance }); break; } case 'VOCAB_HIGHLIGHT_SAVE': - if (typeof window.saveReadingHighlightVocab === 'function') { - await window.saveReadingHighlightVocab(data); + if (!data || !String(data.requestId || '').trim()) { + this._reportExamMessageRejected(examId, type, 'missing-request-id', event); + break; + } + try { + const saved = typeof window.saveReadingHighlightVocab === 'function' + ? await window.saveReadingHighlightVocab(data) + : null; + this._announceVocabHighlightOutcome( + examId, + data, + sourceWindow || expectedWindow, + Boolean(saved), + saved ? '' : 'save_failed' + ); + } catch (saveError) { + console.warn('[VocabStore] 阅读高亮生词保存异常:', saveError); + this._announceVocabHighlightOutcome( + examId, + data, + sourceWindow || expectedWindow, + false, + 'save_failed' + ); } break; case 'REVIEW_NAVIGATE': @@ -2215,6 +2547,12 @@ } } break; + case 'READING_DRAFT_SYNC': + await this._queueReadingDraftSync(routedExamId, data, windowInfo); + break; + case 'READING_ANNOTATION_SYNC': + await this._queueReadingAnnotationSync(routedExamId, data, windowInfo); + break; case 'SIMULATION_NAVIGATE': if (typeof this._handleSimulationNavigate === 'function') { await this._handleSimulationNavigate(routedExamId, data, sourceWindow || expectedWindow); @@ -2285,12 +2623,31 @@ this.messageHandlers.set(examId, messageHandler); // 向题目窗口发送初始化消息(兼容 0.2 增强器监听的 INIT_SESSION) - const sendInitEnvelope = (targetWindow) => { + const sendInitEnvelope = async (targetWindow) => { try { const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo); - targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); } catch (initError) { console.warn('[App] 发送初始化消息失败:', initError); } @@ -2341,17 +2698,35 @@ let attempts = 0; const maxAttempts = 30; // ~9s - const tick = () => { + const tick = async () => { if (examWindow && !examWindow.closed) { try { const windowInfo = this.ensureExamWindowSession(examId, examWindow); + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo); windowInfo.handshakeAttempts = attempts + 1; windowInfo.lastHandshakeAt = Date.now(); this.examWindows && this.examWindows.set(examId, windowInfo); // 直接发送两种事件名,确保增强器任何实现都能收到 - examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, examWindow, 'init_exam_session', initPayload); } catch (_) { /* 忽略 */ } } attempts++; @@ -2361,94 +2736,12 @@ console.warn('[App] 握手超时,练习页可能未加载增强器'); } }; - const timer = setInterval(tick, 300); + const timer = setInterval(() => { tick(); }, 300); this._handshakeTimers.set(examId, timer); // 立即发送一次 tick(); }, - /** - * 创建降级记录器 - */ - createFallbackRecorder() { - return { - handleRealPracticeData: async (examId, realData) => { - try { - // 获取题目信息 - const exam = await findExamDefinition(examId); - - if (!exam) { - console.error('[FallbackRecorder] 无法找到题目信息:', examId); - return null; - } - - const api = window.PracticeRecordAPI; - if (!api || typeof api.saveCompletion !== 'function') { - throw new Error('统一练习记录 API 未就绪'); - } - const practiceRecord = await api.saveCompletion(realData, { - examId, - sessionId: realData && realData.sessionId ? realData.sessionId : null, - examEntry: exam, - metadata: { - examId, - examTitle: exam.title || realData?.title || '', - category: exam.category || realData?.category || 'unknown', - frequency: exam.frequency || realData?.frequency || 'unknown', - type: exam.type || realData?.type || null - } - }); - - // 检查成就 - if (window.AchievementManager) { - window.AchievementManager.check(practiceRecord).catch(console.warn); - } - - return practiceRecord; - } catch (error) { - console.error('[FallbackRecorder] 保存失败:', error); - return null; - } - }, - - startSession: (examId) => { - // 简单的会话管理 - return { - examId: examId, - startTime: new Date().toISOString(), - sessionId: this.generateSessionId(examId), - status: 'started' - }; - }, - - getPracticeRecords: async (filters = {}) => { - try { - const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function' - ? await window.PracticeRecordAPI.list() - : []; - - if (Object.keys(filters).length === 0) { - return records; - } - - return records.filter(record => { - if (filters.examId && record.examId !== filters.examId) return false; - if (filters.category && record.category !== filters.category) return false; - if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false; - if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false; - if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false; - if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false; - - return true; - }); - } catch (error) { - console.error('[FallbackRecorder] 获取记录失败:', error); - return []; - } - } - }; - }, - // ExamBrowser组件已移除,使用内置的题目列表功能 /** @@ -2568,7 +2861,15 @@ }, generateWindowSessionToken(examId) { - const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; + const cryptoApi = global.crypto; + if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') { + throw new Error('Secure random generator is required for window session tokens'); + } + const bytes = new Uint8Array(24); + cryptoApi.getRandomValues(bytes); + const suffix = Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); const normalizedExamId = typeof examId === 'string' ? examId.trim().replace(/\s+/g, '-') : (examId != null ? String(examId).trim().replace(/\s+/g, '-') : ''); @@ -2971,6 +3272,27 @@ : (Array.isArray(entry.realData?.highlights) ? entry.realData.highlights.slice() : (Array.isArray(record.realData?.highlights) ? record.realData.highlights.slice() : []))); + const noteText = typeof entry.noteText === 'string' + ? entry.noteText + : (typeof entry.rawData?.noteText === 'string' + ? entry.rawData.noteText + : (typeof entry.realData?.noteText === 'string' + ? entry.realData.noteText + : (typeof record.realData?.noteText === 'string' ? record.realData.noteText : ''))); + const notes = Array.isArray(entry.notes) + ? this._cloneReviewData(entry.notes) + : (Array.isArray(entry.rawData?.notes) + ? this._cloneReviewData(entry.rawData.notes) + : (Array.isArray(entry.realData?.notes) + ? this._cloneReviewData(entry.realData.notes) + : (Array.isArray(record.realData?.notes) ? this._cloneReviewData(record.realData.notes) : []))); + const noteOutlines = Array.isArray(entry.noteOutlines) + ? this._cloneReviewData(entry.noteOutlines) + : (Array.isArray(entry.rawData?.noteOutlines) + ? this._cloneReviewData(entry.rawData.noteOutlines) + : (Array.isArray(entry.realData?.noteOutlines) + ? this._cloneReviewData(entry.realData.noteOutlines) + : (Array.isArray(record.realData?.noteOutlines) ? this._cloneReviewData(record.realData.noteOutlines) : []))); const scrollY = Number.isFinite(Number(entry.scrollY)) ? Number(entry.scrollY) : (Number.isFinite(Number(entry.rawData?.scrollY)) @@ -3004,6 +3326,9 @@ ? entryMetadata.markedQuestions.slice() : (Array.isArray(recordMetadata.markedQuestions) ? recordMetadata.markedQuestions.slice() : [])), highlights, + noteText, + notes, + noteOutlines, scrollY, metadata: mergedMetadata }; @@ -3020,6 +3345,22 @@ return this.reviewReplaySessions; }, + async _resolveReviewExamDefinition(entry) { + if (!entry || typeof entry !== 'object' || !entry.examId) { + throw new Error('历史记录缺少题目标识'); + } + if (typeof window.resolveExamForPracticeRecord !== 'function') { + throw new Error('历史记录题库解析器不可用'); + } + const exam = await window.resolveExamForPracticeRecord(entry); + if (exam) return exam; + // resolveExamForPracticeRecord 在记录缺 provenance 时已回退到当前活动题库解析 + // (见 libraryManager.resolveIndexForRecord)。走到这里说明 examId 在可解析的题库中 + // 确实不存在——统一按“题目不可用”处理,不再因缺少 libraryConfigurationId 而拒绝回放, + // 那会误伤所有 v1 迁移来、迁移时无法唯一判定来源的旧记录。 + throw new Error('该记录对应的题目在当前题库中不存在,可能题库已被删除或切换'); + }, + _buildReviewSession(record) { const entries = this._buildReviewReplayEntriesFromRecord(record); const validEntries = entries.filter((entry) => entry && entry.examId); @@ -3028,6 +3369,7 @@ } return { sessionId: `review_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + recordId: record && record.id != null ? String(record.id) : '', entries: validEntries, currentIndex: 0, windowRef: null, @@ -3035,6 +3377,492 @@ }; }, + _cloneReadingDraftValue(value) { + if (value == null) { + return value; + } + try { + return JSON.parse(JSON.stringify(value)); + } catch (_) { + if (Array.isArray(value)) { + return value.slice(); + } + if (value && typeof value === 'object') { + return Object.assign({}, value); + } + return value; + } + }, + + _readingDraftId(examId, libraryConfigurationId = null) { + const normalizedExamId = String(examId || '').trim(); + const normalizedConfigurationId = libraryConfigurationId == null + ? '' + : String(libraryConfigurationId).trim(); + return normalizedConfigurationId + ? `reading-draft:${normalizedExamId}:${normalizedConfigurationId}` + : `reading-draft:${normalizedExamId}`; + }, + + _buildReadingDraftSnapshot(examId, data = {}, windowInfo = null) { + const source = data && data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft) + ? data.draft + : (data && typeof data === 'object' ? data : {}); + const answers = source.answers && typeof source.answers === 'object' && !Array.isArray(source.answers) + ? this._cloneReadingDraftValue(source.answers) + : {}; + const highlights = Array.isArray(source.highlights) ? this._cloneReadingDraftValue(source.highlights) : []; + const notes = Array.isArray(source.notes) ? this._cloneReadingDraftValue(source.notes) : []; + const noteOutlines = Array.isArray(source.noteOutlines) ? this._cloneReadingDraftValue(source.noteOutlines) : []; + const markedQuestions = Array.isArray(source.markedQuestions) + ? this._cloneReadingDraftValue(source.markedQuestions) + : []; + const noteText = typeof source.noteText === 'string' ? source.noteText : ''; + const scrollY = Number.isFinite(Number(source.scrollY)) ? Math.max(0, Number(source.scrollY)) : 0; + const updatedAt = Number(data.draftUpdatedAt ?? source.updatedAt); + const sessionId = data.sessionId != null + ? String(data.sessionId) + : (windowInfo && windowInfo.expectedSessionId ? String(windowInfo.expectedSessionId) : ''); + const libraryConfigurationId = this._readLaunchLibraryConfigurationId(examId, windowInfo); + return { + id: this._readingDraftId(examId, libraryConfigurationId), + examId: String(examId), + libraryConfigurationId: libraryConfigurationId == null ? null : String(libraryConfigurationId), + sessionId, + answers, + highlights, + notes, + noteOutlines, + markedQuestions, + noteText, + scrollY, + updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(), + status: 'in_progress', + kind: 'reading_draft' + }; + }, + + async _readReadingDraftStore() { + const drafts = await window.AppData.recovery.listDrafts(); + const store = {}; + (Array.isArray(drafts) ? drafts : []).forEach((draft) => { + if (draft && draft.kind === 'reading_draft' && draft.examId) { + const id = draft.id || this._readingDraftId(draft.examId, draft.libraryConfigurationId); + store[String(id)] = draft; + } + }); + return store; + }, + + async _writeReadingDraftStore(store, changedDraft = null) { + try { + if (changedDraft) { + await window.AppData.recovery.saveDraft(changedDraft); + } + const drafts = await window.AppData.recovery.listDrafts(); + const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); + for (const draft of Array.isArray(drafts) ? drafts : []) { + const numericUpdatedAt = Number(draft && draft.updatedAt); + const draftUpdatedAt = Number.isFinite(numericUpdatedAt) + ? numericUpdatedAt + : Date.parse(draft && draft.updatedAt); + if ( + draft + && draft.kind === 'reading_draft' + && draft.id !== changedDraft?.id + && (!Number.isFinite(draftUpdatedAt) || draftUpdatedAt < cutoff) + ) { + await window.AppData.recovery.discardDraft(draft.id); + } + } + return true; + } catch (error) { + console.warn('[ReadingDraftGateway] 写入草稿失败:', error); + return false; + } + }, + + async handleReadingDraftSync(examId, data = {}, windowInfo = null) { + const info = windowInfo || (this.examWindows && this.examWindows.get(examId)); + if (!info || info.reviewMode) { + return false; + } + if (String(info.practiceMode || '').toLowerCase() === 'memorize') { + return false; + } + // 用“本窗口的 suite 绑定”判断是否套题草稿,而不是看全局 currentSuiteSession: + // 否则当任意套题会话仍活跃时,普通独立阅读窗口(windowInfo.suiteSessionId 为空) + // 的草稿也会被拒绝,关闭该窗口会丢失该题的在做答案/笔记。 + if (info.suiteSessionId) { + // Suite drafts stay on the suite session path. + return false; + } + const expectedSessionId = info.expectedSessionId ? String(info.expectedSessionId) : ''; + const payloadSessionId = data && data.sessionId != null ? String(data.sessionId) : ''; + if (!expectedSessionId || !payloadSessionId || payloadSessionId !== expectedSessionId) { + return false; + } + const draft = this._buildReadingDraftSnapshot(examId, data, info); + if (!draft.sessionId) { + return false; + } + // 必须在写队列里重新读取最新 store 再合并,否则并发不同 exam 的 write 会互相覆盖、 + // 后写者会丢掉前者的草稿(整个 map 是同一个存储 key,read-modify-write 非原子)。 + const store = await this._readReadingDraftStore(); + const previous = store[String(draft.id)] || null; + const previousNumericUpdatedAt = Number(previous && previous.updatedAt); + const previousUpdatedAt = Number.isFinite(previousNumericUpdatedAt) + ? previousNumericUpdatedAt + : Date.parse(previous && previous.updatedAt); + const nextNumericUpdatedAt = Number(draft.updatedAt); + const nextUpdatedAt = Number.isFinite(nextNumericUpdatedAt) + ? nextNumericUpdatedAt + : Date.parse(draft.updatedAt); + if ( + previous + && previous.sessionId === draft.sessionId + && Number.isFinite(previousUpdatedAt) + && Number.isFinite(nextUpdatedAt) + && nextUpdatedAt < previousUpdatedAt + ) { + return false; + } + store[String(draft.id)] = draft; + if (!await this._writeReadingDraftStore(store, draft)) { + return false; + } + info.lastReadingDraft = draft; + info.lastReadingDraftAt = Date.now(); + if (this.examWindows) { + this.examWindows.set(examId, info); + } + return true; + }, + + async _queueReadingDraftSync(examId, data = {}, windowInfo = null) { + // 同一宿主窗口内保持事件顺序;跨标签并发由 AppData/kernel CAS 处理。 + if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') { + this._readingDraftStoreQueue = Promise.resolve(); + } + const queued = this._readingDraftStoreQueue + .catch(() => undefined) + .then(() => this.handleReadingDraftSync(examId, data, windowInfo)); + this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => { + if (this._readingDraftStoreQueue === queued) { + this._readingDraftStoreQueue = Promise.resolve(); + } + }); + return queued; + }, + + async getReadingDraftForExam(examId, options = {}) { + const normalizedExamId = examId != null ? String(examId).trim() : ''; + if (!normalizedExamId) { + return null; + } + const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId') + ? options.libraryConfigurationId + : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo); + const store = await this._readReadingDraftStore(); + const draft = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null; + if (!draft || typeof draft !== 'object') { + return null; + } + // 仅用于“恢复未完成草稿”:跨开窗/重启时 expectedSessionId 会重新生成, + // 旧 draft 的 sessionId 必然与之不同;读取不写入任何数据,无跨会话覆盖风险, + // 因此这里不再用 sessionId 拦截,把旧草稿透传给调用方,由其在新 session 里继续答题。 + // 写/清路径仍保留严格校验,避免跨会话误覆盖或误删。 + const cloned = this._cloneReadingDraftValue(draft); + const expectedSessionId = options.sessionId != null ? String(options.sessionId) : ''; + if (expectedSessionId && String(cloned.sessionId || '') !== expectedSessionId) { + cloned.sessionId = expectedSessionId; + } + return cloned; + }, + + async clearReadingDraftForExam(examId, options = {}) { + const normalizedExamId = examId != null ? String(examId).trim() : ''; + if (!normalizedExamId) { + return false; + } + const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId') + ? options.libraryConfigurationId + : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo); + const run = async () => { + const store = await this._readReadingDraftStore(); + const existing = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null; + if (!existing) { + return false; + } + const expectedSessionId = options.sessionId != null ? String(options.sessionId) : ''; + // completion 路径用 acceptResumeSessionId=true 调用:若用户是在恢复的草稿上继续答题, + // 存档里仍是恢复前的旧 sessionId,而完成事件带的是新 session id; + // 这里已由完成事件本身做过严格的 message/session 校验,可直接删除该题草稿, + // 避免已提交的答案在重开 SAME 题时被旧草稿复活。 + if (expectedSessionId && String(existing.sessionId || '') !== expectedSessionId && !options.acceptResumeSessionId) { + return false; + } + await window.AppData.recovery.discardDraft(existing.id); + return true; + }; + // 与当前窗口的 draft sync 顺序一致,物理并发控制仍由 kernel 负责。 + if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') { + this._readingDraftStoreQueue = Promise.resolve(); + } + const queued = this._readingDraftStoreQueue + .catch(() => undefined) + .then(run); + this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => { + if (this._readingDraftStoreQueue === queued) { + this._readingDraftStoreQueue = Promise.resolve(); + } + }); + return queued; + }, + + async _isPracticeCompletionPersisted(record) { + const identityFields = ['id', 'examId', 'sessionId']; + const completionTime = (value) => value && ( + value.endTime || value.completedAt || value.timestamp || value.date + ); + if (!record || typeof record !== 'object' + || identityFields.some((key) => record[key] == null || String(record[key]).trim() === '') + || !completionTime(record)) { + return false; + } + try { + const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' }); + if (!persisted || typeof persisted !== 'object') { + return false; + } + return identityFields.every((key) => String(persisted[key] ?? '') === String(record[key])) + && String(completionTime(persisted) || '') === String(completionTime(record)); + } catch (error) { + console.warn('[ReadingDraftGateway] 无法确认完成记录已落库,保留草稿:', error); + return false; + } + }, + + async handleReadingAnnotationSync(examId, data = {}, windowInfo = null) { + const info = windowInfo || (this.examWindows && this.examWindows.get(examId)); + if (!info) { + return false; + } + // 两条来源均可落库标注:①review 回放态,按 reviewSessionId 解析 recordId; + // ②单篇阅读 final-submit 后的结果页,按 windowInfo.submittedRecordId 直连 + // 已存档的练习记录。两者都需要 payload.recordId 与解析出的 recordId 严格匹配。 + let recordId = ''; + if (info.reviewMode && info.reviewSessionId) { + const reviewSessionId = String(info.reviewSessionId); + const sessions = this._ensureReviewReplayStore(); + const reviewSession = sessions.get(reviewSessionId); + if (!reviewSession || !reviewSession.recordId) { + return false; + } + recordId = String(reviewSession.recordId); + } else if (info.submittedRecordId) { + recordId = String(info.submittedRecordId); + } else { + return false; + } + if (data.recordId == null || String(data.recordId) !== recordId) { + return false; + } + + const source = data.annotations && typeof data.annotations === 'object' && !Array.isArray(data.annotations) + ? data.annotations + : data; + const annotationPatch = {}; + ['highlights', 'notes', 'noteOutlines', 'markedQuestions'].forEach((key) => { + if (Object.prototype.hasOwnProperty.call(source, key) && Array.isArray(source[key])) { + annotationPatch[key] = this._cloneReviewData(source[key]); + } + }); + if (Object.prototype.hasOwnProperty.call(source, 'noteText') && typeof source.noteText === 'string') { + annotationPatch.noteText = source.noteText; + } + if (Object.prototype.hasOwnProperty.call(source, 'scrollY')) { + const scrollY = Number(source.scrollY); + if (Number.isFinite(scrollY)) { + annotationPatch.scrollY = Math.max(0, scrollY); + } + } + if (Object.keys(annotationPatch).length === 0) { + return false; + } + + const normalizedExamId = String(examId); + await window.AppData.practice.updateAnnotations({ + recordId, + examId: normalizedExamId, + patch: annotationPatch, + operationId: data.operationId || data.messageId || undefined + }); + + // 只有 review 回放分支需要同时更新内存中的 reviewSession.entries; + // 单篇 submitted 直连已存档记录的分支不持有 reviewSession,跳过。 + if (info.reviewMode && info.reviewSessionId) { + const reviewSessionId = String(info.reviewSessionId); + const sessions = this._ensureReviewReplayStore(); + const reviewSession = sessions.get(reviewSessionId); + if (reviewSession && Array.isArray(reviewSession.entries)) { + reviewSession.entries = reviewSession.entries.map((entry) => ( + entry && String(entry.examId) === normalizedExamId + ? Object.assign({}, entry, annotationPatch) + : entry + )); + sessions.set(reviewSessionId, reviewSession); + } + } + return true; + }, + + async _queueReadingAnnotationSync(examId, data = {}, windowInfo = null) { + return this.handleReadingAnnotationSync(examId, data, windowInfo); + }, + + // 单篇阅读 final-submit 落库成功后,把已存档 recordId 写入 windowInfo 并 + // postMessage 回结果页,使结果页笔记改动能以 READING_ANNOTATION_SYNC + // 持久化回该练习记录。套题流程不会走到这里(已在 handleSuitePracticeComplete 早退)。 + _announceSubmittedReadingRecord(examId, savedRecord, completionData, sourceWindow) { + try { + const recordId = savedRecord && savedRecord.id != null ? String(savedRecord.id).trim() : ''; + if (!recordId) { + return false; + } + const sessionId = completionData && completionData.sessionId != null + ? String(completionData.sessionId) + : ''; + const targetWindow = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null; + if (!targetWindow) { + return false; + } + const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + if (windowInfo) { + windowInfo.submittedRecordId = recordId; + windowInfo.window = targetWindow; + windowInfo.status = 'completed'; + windowInfo.completedAt = windowInfo.completedAt || Date.now(); + this.examWindows && this.examWindows.set(examId, windowInfo); + } + this._postExamMessage(examId, targetWindow, 'PRACTICE_RECORD_SAVED', { + examId, + recordId, + sessionId: sessionId || null + }); + return true; + } catch (_) { + // annotation persistence hint is best-effort + return false; + } + }, + + _announcePracticeSubmitOutcome(examId, completionData, sourceWindow, succeeded, details = {}) { + const submissionId = completionData && completionData.submissionId != null + ? String(completionData.submissionId).trim() + : ''; + const sessionId = completionData && completionData.sessionId != null + ? String(completionData.sessionId).trim() + : ''; + const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null; + if (!submissionId || !sessionId || !targetWindow) { + return false; + } + try { + const type = succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED'; + const payload = { + examId, + submissionId, + sessionId, + suiteSessionId: completionData && completionData.suiteSessionId + ? String(completionData.suiteSessionId) + : null, + errorCode: succeeded ? null : String(details.errorCode || 'save_failed') + }; + const delivered = this._postExamMessage(examId, targetWindow, type, payload); + if (succeeded) { + const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + const receiptKey = `${sessionId}:${submissionId}`; + const receipts = windowInfo.practiceSubmitReceipts && typeof windowInfo.practiceSubmitReceipts === 'object' + ? windowInfo.practiceSubmitReceipts + : {}; + receipts[receiptKey] = Object.assign({}, payload, { examId, succeeded: true }); + const keys = Object.keys(receipts); + keys.slice(0, Math.max(0, keys.length - 8)).forEach((key) => delete receipts[key]); + windowInfo.practiceSubmitReceipts = receipts; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + return delivered; + } catch (error) { + console.warn('[DataCollection] 提交结果回执发送失败:', error); + return false; + } + }, + + _announceVocabHighlightOutcome(examId, requestData, sourceWindow, succeeded, errorCode = '') { + const requestId = requestData && requestData.requestId != null + ? String(requestData.requestId).trim() + : ''; + const sessionId = requestData && requestData.sessionId != null + ? String(requestData.sessionId).trim() + : ''; + const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null; + if (!requestId || !sessionId || !targetWindow) { + return false; + } + return this._postExamMessage( + examId, + targetWindow, + succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED', + { + examId, + sessionId, + requestId, + errorCode: succeeded ? null : String(errorCode || 'save_failed') + } + ); + }, + + _replayPracticeSubmitReceipt(examId, completionData, sourceWindow) { + const submissionId = completionData && completionData.submissionId != null + ? String(completionData.submissionId).trim() + : ''; + const sessionId = completionData && completionData.sessionId != null + ? String(completionData.sessionId).trim() + : ''; + if (!submissionId || !sessionId || !sourceWindow || sourceWindow.closed) { + return false; + } + const windowInfo = this.ensureExamWindowSession(examId, sourceWindow); + const receipt = windowInfo.practiceSubmitReceipts + && windowInfo.practiceSubmitReceipts[`${sessionId}:${submissionId}`]; + if (!receipt || receipt.succeeded !== true) { + return false; + } + this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, true); + return true; + }, + + _scheduleSuiteSubmitTeardown(session) { + if (!session || typeof this._teardownSuiteSession !== 'function') { + return false; + } + if (session.submitReceiptTeardownTimer) { + clearTimeout(session.submitReceiptTeardownTimer); + } + const timer = setTimeout(() => { + session.submitReceiptTeardownTimer = null; + this._teardownSuiteSession(session).catch((teardownError) => { + console.warn('[SuitePractice] 提交回执重放窗口结束后清理套题会话失败:', teardownError); + }); + }, 30000); + session.submitReceiptTeardownTimer = timer; + if (timer && typeof timer.unref === 'function') { + timer.unref(); + } + return true; + }, + _bindReviewWindowRef(reviewSessionId, windowRef) { if (!reviewSessionId || !windowRef || windowRef.closed) { return; @@ -3076,14 +3904,15 @@ } const replayPayload = { reviewSessionId: session.sessionId, + recordId: session.recordId || null, reviewEntryIndex: safeIndex, readOnly: session.readOnly !== false, entry: this._cloneReviewData(entry) }; const contextPayload = this._buildReviewContextPayload(session, safeIndex); try { - targetWindow.postMessage({ type: 'REPLAY_PRACTICE_RECORD', data: replayPayload }, '*'); - targetWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'REPLAY_PRACTICE_RECORD', replayPayload); + this._postExamMessage(examId, targetWindow, 'REVIEW_CONTEXT', contextPayload); return true; } catch (error) { console.warn('[ReviewReplay] 向题目页发送回放数据失败:', error); @@ -3178,12 +4007,15 @@ console.warn('[ReviewReplay] 清理旧题目会话失败:', error); } + const examDefinition = await this._resolveReviewExamDefinition(nextEntry); await this.openExam(nextEntry.examId, { reviewMode: true, readOnly: true, reviewSessionId: sessionId, reviewEntryIndex: nextIndex, - reuseWindow: session.windowRef || null + reuseWindow: session.windowRef || null, + examDefinition, + requireRecordProvenance: true }); }, @@ -3201,11 +4033,14 @@ throw new Error('无法解析首题题目标识'); } + const examDefinition = await this._resolveReviewExamDefinition(firstEntry); const openedWindow = await this.openExam(firstEntry.examId, { reviewMode: true, readOnly: true, reviewSessionId: session.sessionId, - reviewEntryIndex: 0 + reviewEntryIndex: 0, + examDefinition, + requireRecordProvenance: true }); if (!openedWindow) { store.delete(session.sessionId); @@ -3224,6 +4059,14 @@ const suiteSessionId = typeof this._resolveSuiteSessionId === 'function' ? this._resolveSuiteSessionId(examId, info) : (info.suiteSessionId || null); + const activeSuite = suiteSessionId + && this.currentSuiteSession + && String(this.currentSuiteSession.id || '') === String(suiteSessionId) + ? this.currentSuiteSession + : null; + const autoAdvanceAfterSubmit = activeSuite && typeof activeSuite.autoAdvanceAfterSubmit === 'boolean' + ? activeSuite.autoAdvanceAfterSubmit + : (typeof info.autoAdvanceAfterSubmit === 'boolean' ? info.autoAdvanceAfterSubmit : null); const timerContext = typeof this._resolveSuiteTimerContext === 'function' ? this._resolveSuiteTimerContext({}, info) : { @@ -3235,14 +4078,20 @@ ? Math.floor(Number(extras.messageIssuedAtMs ?? extras.timestamp)) : Date.now(); info.lastInitMessageAt = messageIssuedAtMs; + // 启动时捕获的题库配置 ID:优先用 windowInfo 上预存值(启动时埋下), + // 否则从 mixin 私有 Map 兜底读,确保随 INIT_SESSION 携带到考试窗口。 + const launchLibraryConfigurationId = Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId') + ? info.libraryConfigurationId + : this._readLaunchLibraryConfigurationId(examId); const payload = { examId: examId, - parentOrigin: window.location.origin, + parentOrigin: info.allowOpaqueOrigin ? 'null' : window.location.origin, sessionId: info.expectedSessionId, windowSessionToken: info.windowSessionToken || null, messageIssuedAtMs, suiteSessionId: suiteSessionId || null, suiteFlowMode: info.suiteFlowMode || null, + autoAdvanceAfterSubmit, suiteTimerAnchorMs: timerContext.suiteTimerAnchorMs || null, globalTimerAnchorMs: timerContext.globalTimerAnchorMs || null, suiteTimerMode: timerContext.suiteTimerMode || null, @@ -3262,23 +4111,62 @@ reviewEntryIndex: Number.isInteger(info.reviewEntryIndex) ? info.reviewEntryIndex : 0, readOnly: Object.prototype.hasOwnProperty.call(info, 'readOnly') ? Boolean(info.readOnly) - : Boolean(info.reviewMode) + : Boolean(info.reviewMode), + libraryConfigurationId: launchLibraryConfigurationId }; + if ( + !payload.reviewMode + && !suiteSessionId + && !payload.suiteFlowMode + && info.lastReadingDraft + && typeof info.lastReadingDraft === 'object' + && String(info.lastReadingDraft.sessionId || '') === String(info.expectedSessionId || '') + ) { + payload.draft = this._cloneReadingDraftValue(info.lastReadingDraft); + } if (extras && typeof extras === 'object') { Object.assign(payload, extras); } + // extras 显式提供 libraryConfigurationId 时不被覆盖;若 extras 显式带 + // undefined/null(不应出现),保留启动捕获值以免丢失题库来源。 + if (extras && typeof extras === 'object' + && Object.prototype.hasOwnProperty.call(extras, 'libraryConfigurationId')) { + payload.libraryConfigurationId = extras.libraryConfigurationId; + } else if (payload.libraryConfigurationId === undefined) { + payload.libraryConfigurationId = launchLibraryConfigurationId; + } return payload; }, - _sendExamInitEnvelope(examId, targetWindow, extras = {}) { + async _sendExamInitEnvelope(examId, targetWindow, extras = {}) { if (!targetWindow || targetWindow.closed) { return null; } try { const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + && !(extras && Object.prototype.hasOwnProperty.call(extras, 'draft')) + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo, extras); - targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); return initPayload; } catch (initError) { console.warn('[App] 发送初始化消息失败:', initError); @@ -3311,7 +4199,10 @@ expectedSessionId: this.generateSessionId(examId), windowSessionToken: null, windowSessionTokenSessionId: null, - origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '', + expectedUrl: '', + expectedOrigin: '', + allowOpaqueOrigin: false, + observedOrigin: '', suiteTimerAnchorMs: null, globalTimerAnchorMs: null, suiteTimerMode: null, @@ -3323,7 +4214,8 @@ reviewMode: false, reviewSessionId: null, reviewEntryIndex: 0, - readOnly: false + readOnly: false, + submittedRecordId: '' }); } @@ -3333,6 +4225,28 @@ windowInfo.window = examWindow; } + if (!windowInfo.expectedOrigin && examWindow) { + try { + const currentHref = examWindow.location && typeof examWindow.location.href === 'string' + ? examWindow.location.href + : ''; + const endpoint = this._resolveExamMessageEndpoint(currentHref); + const hostOrigin = window.location && window.location.origin; + const isTrustedSameOrigin = endpoint.expectedOrigin + && endpoint.expectedOrigin !== 'null' + && hostOrigin + && endpoint.expectedOrigin === hostOrigin; + const isTrustedLocalFile = endpoint.allowOpaqueOrigin && isFileProtocol; + if (isTrustedSameOrigin || isTrustedLocalFile) { + windowInfo.expectedUrl = endpoint.expectedUrl; + windowInfo.expectedOrigin = endpoint.expectedOrigin; + windowInfo.allowOpaqueOrigin = endpoint.allowOpaqueOrigin; + } + } catch (_) { + // Cross-origin WindowProxy locations are intentionally not probed further. + } + } + if (!windowInfo.expectedSessionId) { windowInfo.expectedSessionId = this.generateSessionId(examId); } @@ -3356,17 +4270,94 @@ return windowInfo; }, + /** + * 在考试启动时捕获当前激活的题库配置 ID,写入 windowInfo 与 mixin 私有 Map, + * 供后续 INIT_SESSION payload 以及 completeAttempt 路径使用,避免提交时再读取 + * 当前激活题库而拿到不一致的来源。 + * 该方法为 async:必要时调用方需 await。 + */ + async _captureLaunchLibraryConfigurationId(examId) { + if (!examId) return null; + if (!this._launchLibraryConfigurationIds) { + this._launchLibraryConfigurationIds = new Map(); + } + let configurationId = null; + try { + if (window.AppData && window.AppData.library + && typeof window.AppData.library.getActive === 'function') { + configurationId = await window.AppData.library.getActive(); + } + } catch (captureError) { + console.warn('[ExamSession] 捕获启动题库配置 ID 失败:', captureError); + configurationId = null; + } + const normalized = (configurationId === undefined || configurationId === null) + ? null + : configurationId; + this._launchLibraryConfigurationIds.set(String(examId), normalized); + // 同步作用中 windowInfo:避免后续 _buildExamInitPayload 等同步路径漏读 + try { + if (this.examWindows && this.examWindows.has(examId)) { + const windowInfo = this.examWindows.get(examId); + if (windowInfo && typeof windowInfo === 'object' + && !Object.prototype.hasOwnProperty.call(windowInfo, 'libraryConfigurationId')) { + windowInfo.libraryConfigurationId = normalized; + } + } + } catch (_) { /* 忽略:windowInfo 不存在不影响捕获 */ } + return normalized; + }, + + /** + * 同步读取指定 examId 启动时捕获的题库配置 ID;若无捕获返回 null。 + * 优先取实时注入(realData.metadata / payload 显式传入)的值,再回退到启动时捕获值。 + */ + _readLaunchLibraryConfigurationId(examId, ...fromSources) { + for (const source of fromSources) { + if (source !== undefined && source !== null && typeof source === 'object') { + const metadata = source.metadata; + const direct = Object.prototype.hasOwnProperty.call(source, 'libraryConfigurationId') + ? source.libraryConfigurationId + : (metadata && Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId')) + ? metadata.libraryConfigurationId + : undefined; + if (direct !== undefined && direct !== null) { + return direct; + } + } + } + if (!this._launchLibraryConfigurationIds) { + return null; + } + return this._launchLibraryConfigurationIds.get(String(examId)) || null; + }, + + /** + * 清除指定 examId 启动时捕获的题库配置 ID(窗口关闭后调用)。 + */ + _discardLaunchLibraryConfigurationId(examId) { + if (this._launchLibraryConfigurationIds && examId) { + this._launchLibraryConfigurationIds.delete(String(examId)); + } + }, + _syncRecorderSessionStarted(examId, windowInfo, metadata = {}) { const recorder = this.components && this.components.practiceRecorder; if (!recorder || typeof recorder.handleSessionStarted !== 'function') { return; } const sessionId = (windowInfo && windowInfo.expectedSessionId) || this.generateSessionId(examId); + // 注入启动时捕获的题库配置 ID,确保 recorder 会话上携带来源。 + const mergedMetadata = Object.assign({}, metadata); + if (!Object.prototype.hasOwnProperty.call(mergedMetadata, 'libraryConfigurationId')) { + mergedMetadata.libraryConfigurationId = + this._readLaunchLibraryConfigurationId(examId, windowInfo, metadata); + } try { recorder.handleSessionStarted({ examId, sessionId, - metadata + metadata: mergedMetadata }); } catch (recorderError) { console.warn('[PracticeRecorder] 重置后同步会话状态失败:', recorderError); @@ -3375,16 +4366,25 @@ async _removeActiveExamSessionMetadata(examId) { try { - const activeSessions = await storage.get('active_sessions', []); - const updatedSessions = Array.isArray(activeSessions) - ? activeSessions.filter(session => session && session.examId !== examId) - : []; - await storage.set('active_sessions', updatedSessions); + await this._discardActiveSessionsForExam(examId); } catch (error) { console.warn('[App] 清理活动会话元数据失败:', error); } }, + async _discardActiveSessionsForExam(examId) { + const activeSessions = await window.AppData.recovery.listActiveSessions(); + const matches = (Array.isArray(activeSessions) ? activeSessions : []) + .filter((session) => session && session.examId === examId); + for (const session of matches) { + const entityId = session.id || session.sessionId || session.recordId; + if (entityId) { + await window.AppData.recovery.discardActiveSession(entityId); + } + } + return matches.length; + }, + _isResetCapableUnifiedReadingCompletion(data, sourceWindow = null) { if (!sourceWindow || sourceWindow.closed) { return false; @@ -3438,6 +4438,7 @@ windowInfo.reviewMode = false; windowInfo.readOnly = false; windowInfo.status = 'active'; + windowInfo.submittedRecordId = ''; this.examWindows && this.examWindows.set(examId, windowInfo); await this.openExam(examId, { target: 'tab', @@ -3458,6 +4459,7 @@ windowInfo.reviewSessionId = null; windowInfo.reviewEntryIndex = 0; windowInfo.readOnly = false; + windowInfo.submittedRecordId = ''; windowInfo.dataCollectorReady = false; windowInfo.lastResetAt = Date.now(); windowInfo.lastResetReason = reason || 'reset'; @@ -3471,7 +4473,7 @@ resetReason: reason || 'reset' }); - this._sendExamInitEnvelope(examId, targetWindow, { + await this._sendExamInitEnvelope(examId, targetWindow, { practiceMode: null, reviewMode: false, readOnly: false @@ -3492,6 +4494,15 @@ } try { + const windowInfo = this.examWindows && this.examWindows.get(examId); + const hostSessionId = windowInfo && windowInfo.expectedSessionId + ? String(windowInfo.expectedSessionId) + : this.generateSessionId(examId); + if (windowInfo && !windowInfo.expectedSessionId) { + windowInfo.expectedSessionId = hostSessionId; + this.examWindows.set(examId, windowInfo); + } + // 优先使用新的练习页面管理器 if (window.practicePageManager) { const sessionId = await window.practicePageManager.startPracticeSession(examId, exam); @@ -3503,27 +4514,46 @@ // 使用练习记录器开始会话 if (this.components.practiceRecorder) { + // 把启动时捕获的题库配置 ID 透传给 recorder,确保会话 metadata 来源稳定。 + const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId); + const startPayload = Object.assign({}, exam, { + sessionId: hostSessionId, + libraryConfigurationId: launchLibraryConfigurationId + }); let sessionData; if (typeof this.components.practiceRecorder.startPracticeSession === 'function') { - sessionData = this.components.practiceRecorder.startPracticeSession(examId, exam); + sessionData = this.components.practiceRecorder.startPracticeSession( + examId, + startPayload + ); } else if (typeof this.components.practiceRecorder.startSession === 'function') { - sessionData = this.components.practiceRecorder.startSession(examId, exam); + sessionData = this.components.practiceRecorder.startSession( + examId, + startPayload + ); } else { console.warn('[App] PracticeRecorder没有可用的启动方法'); sessionData = null; } + if (sessionData && sessionData.sessionId && windowInfo + && windowInfo.expectedSessionId !== sessionData.sessionId) { + // Keep host token/session aligned with whatever the recorder accepted. + windowInfo.expectedSessionId = String(sessionData.sessionId); + this._refreshExamWindowToken(examId, windowInfo); + this.examWindows.set(examId, windowInfo); + } } else { // 降级处理 + const sessionId = hostSessionId; const sessionData = { + id: `active-session:${sessionId}`, examId: examId, startTime: new Date().toISOString(), status: 'started', - sessionId: this.generateSessionId(examId) + sessionId }; - const activeSessions = await storage.get('active_sessions', []); - activeSessions.push(sessionData); - await storage.set('active_sessions', activeSessions); + await window.AppData.recovery.saveActiveSession(sessionData); } // 更新题目状态 @@ -3533,7 +4563,7 @@ console.error('[App] 启动练习会话失败:', error); // 最终降级方案 - this.startPracticeSessionFallback(examId, exam); + await this.startPracticeSessionFallback(examId, exam); } }, @@ -3541,17 +4571,16 @@ * 降级启动练习会话 */ async startPracticeSessionFallback(examId, exam) { - + const sessionId = this.generateSessionId(examId); const sessionData = { + id: `active-session:${sessionId}`, examId: examId, startTime: new Date().toISOString(), status: 'started', - sessionId: this.generateSessionId(examId) + sessionId }; - const activeSessions = await storage.get('active_sessions', []); - activeSessions.push(sessionData); - await storage.set('active_sessions', activeSessions); + await window.AppData.recovery.saveActiveSession(sessionData); // 更新题目状态 this.updateExamStatus(examId, 'in-progress'); @@ -3607,9 +4636,9 @@ || payload.metadata?.source === 'listening_record_bridge' || payload.pageType === 'listening' || payload.type === 'listening'; - const isPreInitListeningReady = Boolean( - isListeningBridgeReady - && payload.initialized === false + const isPreInitReady = (isListeningBridgeReady && payload.initialized === false) || ( + !String(payload.windowSessionToken || '').trim() + && payload.pageType === 'suite-placeholder' ); // 更新会话状态 @@ -3623,15 +4652,15 @@ if (windowInfo) { if (isListeningBridgeReady) { windowInfo.listeningBridgeSeen = true; - windowInfo.listeningBridgeInitialized = !isPreInitListeningReady; + windowInfo.listeningBridgeInitialized = !isPreInitReady; } - if (!isPreInitListeningReady) { + if (!isPreInitReady) { windowInfo.dataCollectorReady = true; } if (payload.pageType) { windowInfo.pageType = payload.pageType; } - if (!isPreInitListeningReady && payload.sessionId && windowInfo.expectedSessionId !== payload.sessionId) { + if (!isPreInitReady && payload.sessionId && windowInfo.expectedSessionId !== payload.sessionId) { windowInfo.expectedSessionId = payload.sessionId; } if (payload.suiteSessionId && !windowInfo.suiteSessionId) { @@ -3655,16 +4684,16 @@ this.examWindows && this.examWindows.set(examId, windowInfo); } - if (isPreInitListeningReady) { + if (isPreInitReady) { try { const targetWindow = (windowInfo && windowInfo.window) || null; if (targetWindow && typeof targetWindow.postMessage === 'function') { const initPayload = this._buildExamInitPayload(examId, windowInfo || {}); - targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); } } catch (initError) { - console.warn('[App] 听力桥预初始化 ready 后补发 INIT_SESSION 失败:', initError); + console.warn('[App] 预初始化 ready 后补发 INIT_SESSION 失败:', initError); } return; } @@ -3677,6 +4706,25 @@ } } + // 手动回看模式的页面可能先以普通 P1/P2 页面类型上报 SESSION_READY, + // 不应依赖 suiteExamMap/页面类型白名单才能补发回看上下文。 + const activeSuite = this.currentSuiteSession; + const stationarySuiteExam = Boolean( + activeSuite + && activeSuite.status === 'active' + && activeSuite.flowMode === 'stationary' + && Array.isArray(activeSuite.sequence) + && activeSuite.sequence.some(item => item && item.examId === examId) + ); + if (stationarySuiteExam && typeof this._sendSuiteReviewState === 'function') { + const targetWindow = windowInfo && windowInfo.window ? windowInfo.window : null; + try { + this._sendSuiteReviewState(activeSuite, examId, targetWindow); + } catch (suiteContextError) { + console.warn('[SuitePractice] 手动回看页面 ready 后补发上下文失败:', suiteContextError); + } + } + if (!(windowInfo && windowInfo.reviewMode) && this.components && this.components.practiceRecorder @@ -3690,7 +4738,9 @@ pageType: payload.pageType || null, url: payload.url || null, title: payload.title || null, - suiteSessionId: payload.suiteSessionId || null + suiteSessionId: payload.suiteSessionId || null, + // 此处是练习页 SESSION_READY 后同步会话状态的时刻,注入启动时捕获的题库配置 ID。 + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, payload, windowInfo) } }); } catch (recorderError) { @@ -3753,11 +4803,7 @@ return signals.includes('listening_record_bridge') || signals.includes('listening'); }, - _ensureRecorderSessionForListeningCompletion(examId, data) { - if (!this._isListeningBridgeCompletionPayload(data)) { - return; - } - + _ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow = null, defaults = {}) { const recorder = this.components && this.components.practiceRecorder; if (!recorder) { return; @@ -3783,18 +4829,35 @@ && typeof recorder.activeSessions.has === 'function' && recorder.activeSessions.has(examId) ); + const pageType = defaults.pageType + || data?.pageType + || data?.metadata?.pageType + || data?.metadata?.type + || data?.type + || 'practice'; + const practiceType = defaults.type + || data?.type + || data?.metadata?.type + || data?.metadata?.examType + || pageType; + const source = defaults.source + || data?.source + || data?.metadata?.source + || 'practice_page'; if (!hasActiveSession && typeof recorder.startPracticeSession === 'function') { try { recorder.startPracticeSession(examId, { + sessionId, title: data?.title || data?.metadata?.examTitle || '', category: data?.category || data?.pageType || data?.metadata?.category || '', frequency: data?.frequency || data?.metadata?.frequency || '', - type: 'listening', - totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0 + type: practiceType, + totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0, + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo) }); } catch (startError) { - console.warn('[PracticeRecorder] 听力完成前补建会话失败:', startError); + console.warn('[PracticeRecorder] 完成前补建会话失败:', startError); } } @@ -3804,17 +4867,18 @@ examId, sessionId, metadata: { - pageType: data?.pageType || 'listening', - type: 'listening', - examType: 'listening', + pageType, + type: practiceType, + examType: defaults.examType || practiceType, url: data?.url || data?.metadata?.url || null, title: data?.title || data?.metadata?.examTitle || null, suiteSessionId: data?.suiteSessionId || data?.metadata?.suiteSessionId || null, - source: data?.source || data?.metadata?.source || 'listening_record_bridge' + source, + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo) } }); } catch (startedError) { - console.warn('[PracticeRecorder] 听力完成前同步会话状态失败:', startedError); + console.warn('[PracticeRecorder] 完成前同步会话状态失败:', startedError); } } }, @@ -3856,6 +4920,9 @@ console.info('[ReadingMemorize] 背题模式完成事件不保存为正式练习记录:', examId); return; } + if (this._replayPracticeSubmitReceipt(examId, data, sourceWindow)) { + return true; + } // 听力桥返回的填空答案直接按 answerComparison 检测,不能依赖题源目录名必须包含 P1/P4。 try { @@ -3897,6 +4964,10 @@ console.warn('[DataCollection] 拼写错误检测失败,已忽略:', error); } this._normalizeListeningSpellingErrors(examId, data); + // Reading/placeholder completions need the same active-session rebind that + // listening already performed: hot-upgraded PracticeRecorder instances otherwise + // reject production saves when activeSessions was empty. + this._ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow); let suiteHandlerDeclined = false; const payloadSuiteSessionId = ( @@ -3919,9 +4990,21 @@ if (shouldDelegateToSuiteHandler && typeof this.handleSuitePracticeComplete === 'function') { try { - const handled = await this.handleSuitePracticeComplete(examId, data, sourceWindow); + const suiteOutcome = await this.handleSuitePracticeComplete(examId, data, sourceWindow); + const handled = suiteOutcome === true || Boolean(suiteOutcome && suiteOutcome.handled); if (handled) { - return; + const committed = !suiteOutcome || typeof suiteOutcome !== 'object' || suiteOutcome.committed !== false; + this._announcePracticeSubmitOutcome(examId, data, sourceWindow, committed, { + errorCode: suiteOutcome && suiteOutcome.errorCode + }); + if (committed && suiteOutcome && suiteOutcome.teardownSession && typeof this._teardownSuiteSession === 'function') { + try { + this._scheduleSuiteSubmitTeardown(suiteOutcome.teardownSession); + } catch (teardownError) { + console.warn('[SuitePractice] 套题已提交,但延迟清理调度失败:', teardownError); + } + } + return committed; } suiteHandlerDeclined = true; } catch (suiteError) { @@ -3938,18 +5021,65 @@ metadata: Object.assign({}, data?.metadata || {}, { allowStandaloneSave: true, suiteRecovery: true }) }) : data; - this._ensureRecorderSessionForListeningCompletion(examId, completionData); + // The generic completion rebind above already covers listening payloads. + let completionCommitted = false; + let completedViaFallback = false; try { + let persistedRecord = null; if (recorder && typeof recorder.handleSessionCompleted === 'function') { try { - await recorder.handleSessionCompleted(completionData); + persistedRecord = await recorder.handleSessionCompleted(completionData); } catch (recErr) { console.warn('[DataCollection] PracticeRecorder 完成事件处理失败,改用降级存储:', recErr); - await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + completedViaFallback = true; } } else { - await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + completedViaFallback = true; + } + + if (!persistedRecord || typeof persistedRecord !== 'object' || !String(persistedRecord.id || '').trim()) { + throw new Error('Practice completion returned without a committed record'); + } + + let completionReadable = false; + if (typeof this._isPracticeCompletionPersisted === 'function') { + try { + completionReadable = await this._isPracticeCompletionPersisted(persistedRecord); + } catch (verificationError) { + console.warn('[DataCollection] 练习记录提交后回读失败,不影响已提交结果:', verificationError); + } + } + if (!completionReadable) { + throw new Error('Practice completion could not be verified in canonical storage'); + } + completionCommitted = true; + + if (completedViaFallback && recorder && typeof recorder.endPracticeSession === 'function') { + recorder.endPracticeSession(examId); + } + + // 单篇阅读 final-submit 落库成功后,把已存档 recordId 回传给结果页, + // 使其可以在只读提交态编辑笔记并以 READING_ANNOTATION_SYNC 持久化回该记录。 + // 套题流程在上方的 handleSuitePracticeComplete 分支已 return,不会走到这里。 + this._announceSubmittedReadingRecord(examId, persistedRecord, completionData, sourceWindow); + this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, true); + + if (typeof this.clearReadingDraftForExam === 'function') { + try { + await this.clearReadingDraftForExam(examId, { + sessionId: completionData && completionData.sessionId + ? String(completionData.sessionId) + : null, + // 完成事件已通过严格的 message/session 校验,删除该题草稿时 + // 允许命中“恢复前的旧 session id”的存档,避免已提交答案被复活。 + acceptResumeSessionId: true + }); + } catch (_) { + // draft cleanup is best-effort + } } // 刷新内存中的练习记录,确保无需手动刷新即可看到 @@ -3957,9 +5087,17 @@ try { if (typeof window.syncPracticeRecords === 'function') { await window.syncPracticeRecords({ forceRender: true }); - } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const latest = await window.PracticeRecordAPI.list(); - this.setState('practice.records', Array.isArray(latest) ? latest : []); + } else { + const [latest, index] = await Promise.all([ + window.AppData.practice.list({ projection: 'light' }), + window.resolveActiveLibraryIndex() + ]); + if (typeof window.refreshBrowseProgressFromRecords === 'function') { + window.refreshBrowseProgressFromRecords(latest, index); + } + if (typeof window.updatePracticeView === 'function') { + window.updatePracticeView(latest, index); + } } } catch (syncErr) { console.error('[DataCollection] 刷新练习记录失败(数据已保存,不影响落库结果):', syncErr); @@ -3982,26 +5120,31 @@ // 显示完成通知(使用真实数据) await this.showRealCompletionNotification(examId, data); - // 检查成就 + // 检查成就(解锁判定由 achievements.progress projector 负责,这里只读取差异并提示) if (window.AchievementManager) { - window.AchievementManager.check(data?.realData).catch(console.warn); - } - - // 刷新练习记录显示 - if (typeof updatePracticeView === 'function') { - updatePracticeView(); + window.AchievementManager.check().catch(console.warn); } } catch (error) { console.error('[DataCollection] 处理练习完成数据失败:', error); window.showMessage && window.showMessage('练习记录保存失败,请稍后重试', 'error'); + this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, false, { + errorCode: 'save_failed' + }); } finally { - if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) { - await this.retainExamWindowAfterCompletion(examId, sourceWindow, completionData); - } else { - this.cleanupExamSession(examId); + if (completionCommitted) { + try { + if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) { + await this.retainExamWindowAfterCompletion(examId, sourceWindow, completionData); + } else { + await this.cleanupExamSession(examId); + } + } catch (cleanupError) { + console.warn('[DataCollection] 练习已提交,但会话清理失败:', cleanupError); + } } } + return completionCommitted; }, /** @@ -4018,12 +5161,7 @@ type: 'data_collection_error' }; - const errorLogs = await storage.get('collection_errors', []); - errorLogs.push(errorInfo); - if (errorLogs.length > 50) { - errorLogs.splice(0, errorLogs.length - 50); - } - await storage.set('collection_errors', errorLogs); + console.warn('[DataCollection] 诊断信息:', errorInfo); // 标记该会话使用模拟数据 if (this.examWindows && this.examWindows.has(examId)) { @@ -4102,17 +5240,16 @@ throw new Error(`无法找到题目信息: ${examId}`); } - const api = window.PracticeRecordAPI; - if (!api || typeof api.saveCompletion !== 'function') { - throw new Error('统一练习记录 API 未就绪'); - } - const metadata = Object.assign({}, realData?.metadata || {}, { examId, examTitle: exam.title || realData?.title || '', category: exam.category || realData?.category || realData?.metadata?.category || 'unknown', frequency: exam.frequency || realData?.frequency || realData?.metadata?.frequency || 'unknown', - type: exam.type || realData?.type || realData?.practiceType || null + type: exam.type || realData?.type || realData?.practiceType || null, + // 启动时捕获的题库配置 ID;优先取 realData.metadata 显式值,再回退到启动时 + // 在 openExam 捕获的 mixin 私有 Map 值,最后显式随 metadata 写入为 null, + // 让记录来源稳定不受到提交时当前激活题库的影响。 + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, realData) }); const payload = Object.assign({}, realData, { @@ -4124,19 +5261,17 @@ metadata }); - const savedRecord = await api.saveCompletion(payload, { - examId, - sessionId: payload.sessionId || realData?.sessionId || null, - examEntry: exam, - metadata - }, exam, { - currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '1.0.0', - maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000, - updateStats: true + const receipt = await window.AppData.practice.completeAttempt({ + record: payload, + operationId: payload.operationId + || payload.messageId + || (payload.submissionId + ? `practice-complete:${String(payload.examId || examId)}:${String(payload.sessionId || 'session')}:${String(payload.submissionId)}` + : undefined) }); console.log('[DataCollection] 练习完成数据已保存到 canonical store'); - return savedRecord; + return receipt.record; } catch (error) { console.error('[DataCollection] 保存真实数据失败:', error); throw error; @@ -4389,9 +5524,7 @@ } // 清理活动会话 - const activeSessions = await storage.get('active_sessions', []); - const updatedSessions = activeSessions.filter(session => session.examId !== examId); - await storage.set('active_sessions', updatedSessions); + await this._discardActiveSessionsForExam(examId); }, /** @@ -4573,7 +5706,7 @@ * 显示活动会话详情 */ async showActiveSessionsDetails() { - const activeSessions = await storage.get('active_sessions', []); + const activeSessions = await window.AppData.recovery.listActiveSessions(); const examIndex = await getActiveExamIndexSnapshot(); if (activeSessions.length === 0) { @@ -4664,7 +5797,7 @@ * 关闭所有题目会话 */ async closeAllExamSessions() { - const activeSessions = await storage.get('active_sessions', []); + const activeSessions = await window.AppData.recovery.listActiveSessions(); activeSessions.forEach(session => { this.closeExamSession(session.examId); diff --git a/js/app/state-service.js b/js/app/state-service.js index 56524814..87a35dfc 100644 --- a/js/app/state-service.js +++ b/js/app/state-service.js @@ -5,32 +5,6 @@ return Array.isArray(value) ? value.slice() : []; } - function cloneValue(value) { - if (value === null || value === undefined) { - return value; - } - if (typeof global.structuredClone === 'function') { - try { - return global.structuredClone(value); - } catch (_) { } - } - try { - return JSON.parse(JSON.stringify(value)); - } catch (_) { - if (Array.isArray(value)) { - return value.map((item) => cloneValue(item)); - } - if (value && typeof value === 'object') { - return Object.assign({}, value); - } - return value; - } - } - - function clonePracticeRecords(records) { - return Array.isArray(records) ? records.map((record) => cloneValue(record)) : []; - } - function cloneSet(value) { if (value instanceof Set) { return new Set(value); @@ -171,8 +145,6 @@ this.globalBindingsInstalled = false; this.state = { - examIndex: cloneArray(global.examIndex), - practiceRecords: [], filteredExams: Array.isArray(global.filteredExams) ? global.filteredExams : [], browseFilter: normalizeFilter(global.__browseFilter), bulkDeleteMode: !!global.bulkDeleteMode, @@ -183,8 +155,6 @@ }; this.listeners = { - examIndex: new Set(), - practiceRecords: new Set(), filteredExams: new Set(), browseFilter: new Set(), bulkDeleteMode: new Set(), @@ -240,13 +210,11 @@ try { if (app.state.exam) { - app.state.exam.index = this.state.examIndex; app.state.exam.currentCategory = this.state.browseFilter.category; app.state.exam.currentExamType = this.state.browseFilter.type; app.state.exam.filteredExams = this.state.filteredExams; } if (app.state.practice) { - app.state.practice.records = clonePracticeRecords(this.state.practiceRecords); app.state.practice.selectedRecords = this.state.selectedRecords; app.state.practice.bulkDeleteMode = this.state.bulkDeleteMode; } @@ -266,12 +234,6 @@ syncFromAppPath(path, value) { switch (path) { - case 'exam.index': - this.setExamIndex(value, { syncApp: false }); - break; - case 'practice.records': - this.setPracticeRecords(value, { syncApp: false }); - break; case 'exam.filteredExams': this.setFilteredExams(value, { syncApp: false }); break; @@ -311,41 +273,6 @@ } } - getExamIndex() { - return this.state.examIndex; - } - - setExamIndex(list, options = {}) { - const normalized = assignExamSequenceNumbers(cloneArray(list)); - this.state.examIndex = normalized; - if (options.syncApp !== false) { - this.applyToApp(); - } - emit(this.listeners, 'examIndex', this.state.examIndex); - return this.state.examIndex; - } - - getPracticeRecords() { - return clonePracticeRecords(this.state.practiceRecords); - } - - setPracticeRecords(records, options = {}) { - const normalized = clonePracticeRecords(records); - this.state.practiceRecords = normalized; - if (options.syncApp !== false) { - this.applyToApp(); - } - emit(this.listeners, 'practiceRecords', clonePracticeRecords(this.state.practiceRecords)); - if (typeof global.updateBrowseAnchorsFromRecords === 'function') { - try { - global.updateBrowseAnchorsFromRecords(clonePracticeRecords(this.state.practiceRecords)); - } catch (error) { - console.warn('[AppStateService] updateBrowseAnchorsFromRecords failed:', error); - } - } - return clonePracticeRecords(this.state.practiceRecords); - } - getFilteredExams() { return this.state.filteredExams; } @@ -600,18 +527,6 @@ const service = this; - globalRef.getExamIndexState = function getExamIndexState() { - return service.getExamIndex(); - }; - globalRef.setExamIndexState = function setExamIndexState(list) { - return service.setExamIndex(list); - }; - globalRef.getPracticeRecordsState = function getPracticeRecordsState() { - return service.getPracticeRecords(); - }; - globalRef.setPracticeRecordsState = function setPracticeRecordsState(records) { - return service.setPracticeRecords(records); - }; globalRef.getFilteredExamsState = function getFilteredExamsState() { return service.getFilteredExams(); }; @@ -671,14 +586,6 @@ }; globalRef.assignExamSequenceNumbers = assignExamSequenceNumbers; - defineGlobalProperty(globalRef, 'examIndex', { - get: () => service.getExamIndex(), - set: (value) => service.setExamIndex(value) - }); - defineGlobalProperty(globalRef, 'practiceRecords', { - get: () => service.getPracticeRecords(), - set: (value) => service.setPracticeRecords(value) - }); defineGlobalProperty(globalRef, 'filteredExams', { get: () => service.getFilteredExams(), set: (value) => service.setFilteredExams(value) diff --git a/js/app/suitePracticeMixin.js b/js/app/suitePracticeMixin.js index 9c7a5660..24ebdf7c 100644 --- a/js/app/suitePracticeMixin.js +++ b/js/app/suitePracticeMixin.js @@ -8,7 +8,7 @@ function resolveSuitePreferenceForMixin(options = {}) { const suitePreferenceUtils = getSuitePreferenceUtils(); if (suitePreferenceUtils && typeof suitePreferenceUtils.resolveSuitePreference === 'function') { - return suitePreferenceUtils.resolveSuitePreference(options); + return suitePreferenceUtils.ensurePracticeConfig().suite || {}; } let flowMode = String(options && options.flowMode || '').trim().toLowerCase(); if (!['classic', 'simulation', 'stationary'].includes(flowMode)) { @@ -171,16 +171,26 @@ } }, async handleSuitePracticeComplete(examId, data, sourceWindow = null) { + const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => ( + data && data.submissionId + ? Object.assign({ + handled: Boolean(handled), + committed: Boolean(committed), + errorCode: errorCode || null + }, extra || {}) + : Boolean(handled) + ); // First check whether this is multi-suite mode (detected via suiteId). if (data && data.suiteId) { - return await this.handleMultiSuitePracticeComplete(examId, data); + const committed = await this.handleMultiSuitePracticeComplete(examId, data); + return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed'); } if (data && data.suiteSubmission === true && typeof this._handleInlineSimulationSuiteSubmit === 'function') { return await this._handleInlineSimulationSuiteSubmit(examId, data, sourceWindow); } const session = this.currentSuiteSession; - if (!session || session.status !== 'active') { + if (!session) { return false; } @@ -190,6 +200,14 @@ if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) { return false; } + if (session.status === 'completed') { + return withSubmitOutcome(true, true, '', data && data.submissionId ? { + teardownSession: session + } : null); + } + if (session.status !== 'active') { + return false; + } const mappingMissing = !this.suiteExamMap || !this.suiteExamMap.has(examId); if (mappingMissing && typeof this._registerSuiteSequence === 'function') { @@ -219,7 +237,7 @@ submittedExamId: examId, sessionId: session.id }); - return true; + return withSubmitOutcome(true, false, 'inactive_suite_exam'); } const derivedDuration = this._deriveSuiteExamElapsedSeconds(session, examId, data && data.duration); @@ -257,7 +275,7 @@ if (replayWindow) { await this._sendSuiteReviewState(session, examId, replayWindow); } - return true; + return withSubmitOutcome(true, true); } session.currentIndex = currentIndex + 1; @@ -266,8 +284,11 @@ // Last passage -> finalize the entire simulation if (session.currentIndex >= session.sequence.length) { - await this.finalizeSuiteRecord(session); - return true; + const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed); + const committed = await this.finalizeSuiteRecord(session, { deferTeardown }); + return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? { + teardownSession: session + } : null); } // Not last -> advance to next passage @@ -279,7 +300,8 @@ } } - return this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId); + const advanced = await this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId); + return withSubmitOutcome(advanced, advanced, advanced ? '' : 'suite_advance_failed'); }, async continueSuitePractice() { @@ -295,8 +317,17 @@ }, async _handleInlineSimulationSuiteSubmit(examId, data, sourceWindow = null) { + const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => ( + data && data.submissionId + ? Object.assign({ + handled: Boolean(handled), + committed: Boolean(committed), + errorCode: errorCode || null + }, extra || {}) + : Boolean(handled) + ); const session = this.currentSuiteSession; - if (!session || session.status !== 'active' || session.flowMode !== 'simulation') { + if (!session || session.flowMode !== 'simulation') { return false; } const payloadSuiteSessionId = data && typeof data.suiteSessionId === 'string' @@ -305,9 +336,17 @@ if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) { return false; } + if (session.status === 'completed') { + return withSubmitOutcome(true, true, '', data && data.submissionId ? { + teardownSession: session + } : null); + } + if (session.status !== 'active') { + return false; + } const suiteEntries = Array.isArray(data && data.suiteEntries) ? data.suiteEntries : []; if (!suiteEntries.length) { - return false; + return withSubmitOutcome(true, false, 'suite_entries_missing'); } const entriesByExam = new Map(); suiteEntries.forEach((entry) => { @@ -317,7 +356,7 @@ } }); if (!entriesByExam.size) { - return false; + return withSubmitOutcome(true, false, 'suite_entries_missing'); } const hasEverySequenceEntry = Array.isArray(session.sequence) && session.sequence.length > 0 @@ -331,7 +370,7 @@ expected: session.sequence.map(item => item && item.examId).filter(Boolean), received: Array.from(entriesByExam.keys()) }); - return false; + return withSubmitOutcome(true, false, 'suite_entries_incomplete'); } session.results = []; @@ -355,6 +394,8 @@ answers: entryPayload.answers || {}, highlights: Array.isArray(entryPayload.highlights) ? entryPayload.highlights.slice() : [], noteText: typeof entryPayload.noteText === 'string' ? entryPayload.noteText : '', + notes: Array.isArray(entryPayload.notes) ? entryPayload.notes.slice() : [], + noteOutlines: Array.isArray(entryPayload.noteOutlines) ? entryPayload.noteOutlines.slice() : [], scrollY: Number.isFinite(Number(entryPayload.scrollY)) ? Number(entryPayload.scrollY) : 0, markedQuestions: Array.isArray(entryPayload.markedQuestions) ? entryPayload.markedQuestions.slice() : [] }, @@ -378,8 +419,11 @@ session.windowRef = sourceWindow; } this._mirrorSessionToStorage(session); - await this.finalizeSuiteRecord(session); - return true; + const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed); + const committed = await this.finalizeSuiteRecord(session, { deferTeardown }); + return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? { + teardownSession: session + } : null); }, _resolveSuitePreference(options = {}) { @@ -435,7 +479,7 @@ if (data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft)) { return true; } - return ['answers', 'highlights', 'noteText', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => ( + return ['answers', 'highlights', 'noteText', 'notes', 'noteOutlines', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => ( Object.prototype.hasOwnProperty.call(data, key) )); }, @@ -467,6 +511,12 @@ const noteTextSource = typeof draftSource.noteText === 'string' ? draftSource.noteText : (data && typeof data.noteText === 'string' ? data.noteText : ''); + const notesSource = Array.isArray(draftSource.notes) + ? draftSource.notes + : (Array.isArray(data && data.notes) ? data.notes : []); + const noteOutlinesSource = Array.isArray(draftSource.noteOutlines) + ? draftSource.noteOutlines + : (Array.isArray(data && data.noteOutlines) ? data.noteOutlines : []); const scrollSource = Number.isFinite(Number(draftSource.scrollY)) ? Number(draftSource.scrollY) : (Number.isFinite(Number(data && data.scrollY)) ? Number(data.scrollY) : 0); @@ -480,6 +530,8 @@ answers: this._cloneSuiteDraftPlainObject(answerSource), highlights: highlightSource.slice(), noteText: noteTextSource, + notes: this._cloneSuitePlainObject(notesSource), + noteOutlines: this._cloneSuitePlainObject(noteOutlinesSource), scrollY: scrollSource, markedQuestions: markedQuestionsSource.slice(), updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now() @@ -555,6 +607,8 @@ delete cloned.highlights; delete cloned.scrollY; delete cloned.noteText; + delete cloned.notes; + delete cloned.noteOutlines; return cloned; }, @@ -573,6 +627,14 @@ if (noteText) { rawData.noteText = noteText; } + const notes = this._resolveSuiteEntryNotes(entry, draft); + if (notes.length > 0) { + rawData.notes = notes; + } + const noteOutlines = this._resolveSuiteEntryNoteOutlines(entry, draft); + if (noteOutlines.length > 0) { + rawData.noteOutlines = noteOutlines; + } return rawData; }, @@ -582,8 +644,11 @@ entry && entry.highlights, entry && entry.rawData && entry.rawData.highlights ]; + // 把“存在数组”视为权威来源(即使为空也可代表用户已清空高亮), + // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 highlights: [] + // 被跳过而回落到旧 entry.rawData.highlights,复活已删除的高亮。 for (const source of sources) { - if (Array.isArray(source) && source.length > 0) { + if (source != null && Array.isArray(source)) { return source.slice(); } } @@ -631,6 +696,40 @@ return ''; }, + _resolveSuiteEntryNotes(entry, draft = null) { + const sources = [ + draft && draft.notes, + entry && entry.notes, + entry && entry.rawData && entry.rawData.notes + ]; + // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条结构笔记), + // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 notes: [] + // 被跳过而回落到旧 entry.rawData.notes,复活已删除的笔记。 + for (const source of sources) { + if (source != null && Array.isArray(source)) { + return this._cloneSuitePlainObject(source); + } + } + return []; + }, + + _resolveSuiteEntryNoteOutlines(entry, draft = null) { + const sources = [ + draft && draft.noteOutlines, + entry && entry.noteOutlines, + entry && entry.rawData && entry.rawData.noteOutlines + ]; + // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条笔记大纲), + // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 noteOutlines: [] + // 被跳过而回落到旧 entry.rawData.noteOutlines,复活已删除的大纲。 + for (const source of sources) { + if (source != null && Array.isArray(source)) { + return this._cloneSuitePlainObject(source); + } + } + return []; + }, + _buildSuiteReplayEntry(session, examId) { if (!session || !Array.isArray(session.results)) { return null; @@ -695,6 +794,8 @@ const highlights = this._resolveSuiteEntryHighlights(result, draft); const noteText = this._resolveSuiteEntryNoteText(result, draft); + const notes = this._resolveSuiteEntryNotes(result, draft); + const noteOutlines = this._resolveSuiteEntryNoteOutlines(result, draft); const scrollY = this._resolveSuiteEntryScrollY(result, draft); const markedQuestions = result && Array.isArray(result.markedQuestions) ? result.markedQuestions.slice() @@ -705,6 +806,8 @@ || markedQuestions.length || highlights.length || noteText + || notes.length + || noteOutlines.length || (Number.isFinite(Number(scrollY)) && Number(scrollY) > 0) ); if (!hasReplayData) { @@ -719,6 +822,8 @@ markedQuestions, highlights, noteText, + notes, + noteOutlines, scrollY }; }, @@ -780,18 +885,15 @@ } try { if (replayEntry) { - resolvedWindow.postMessage({ - type: 'REPLAY_PRACTICE_RECORD', - data: { - suiteSessionId: session.id, - reviewEntryIndex: contextPayload.currentIndex, - readOnly: contextPayload.readOnly !== false, - markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [], - entry: replayEntry - } - }, '*'); + this._postExamMessage(examId, resolvedWindow, 'REPLAY_PRACTICE_RECORD', { + suiteSessionId: session.id, + reviewEntryIndex: contextPayload.currentIndex, + readOnly: contextPayload.readOnly !== false, + markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [], + entry: replayEntry + }); } - resolvedWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*'); + this._postExamMessage(examId, resolvedWindow, 'REVIEW_CONTEXT', contextPayload); return true; } catch (error) { console.warn('[SuitePractice] 发送套题回看上下文失败:', error); @@ -826,7 +928,7 @@ && Number(windowInfo.lastMessageAt) >= startedAt && (!windowInfo.suiteSessionId || windowInfo.suiteSessionId === session.id) && (!windowInfo.windowSessionToken || !windowInfo.lastWindowSessionToken || windowInfo.windowSessionToken === windowInfo.lastWindowSessionToken) - && (!windowInfo.pageType || /unified-reading|suite-placeholder/i.test(String(windowInfo.pageType))) + && (!windowInfo.pageType || /unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(String(windowInfo.pageType))) ); if (readyMatches) { return true; @@ -870,7 +972,7 @@ const pageType = windowInfo && typeof windowInfo.pageType === 'string' ? windowInfo.pageType.toLowerCase() : ''; - if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) { + if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) { return false; } return true; @@ -1006,6 +1108,7 @@ } if (isCrossExamNavigation || !targetWindow) { targetWindow = await this.openExam(targetEntry.examId, { + examDefinition: targetEntry.exam, target: 'tab', windowName: session.windowName || 'ielts-suite-mode-tab', suiteSessionId: session.id, @@ -1083,7 +1186,10 @@ } try { - const opened = await this.openExam(nextEntry.examId, options); + const opened = await this.openExam(nextEntry.examId, { + ...options, + examDefinition: nextEntry.exam + }); if (opened && !opened.closed) { return opened; } @@ -1169,18 +1275,13 @@ startTime: session.startTime, activeExamId: session.activeExamId }; - if (global.sessionStorage) { - global.sessionStorage.setItem('ielts_sim_session', JSON.stringify(snapshot)); - } + global.AppData.recovery.windowSession.save('simulation', snapshot); } catch (_) { /* file:// may not support */ } }, _restoreSessionFromStorage() { try { - if (!global.sessionStorage) return null; - const raw = global.sessionStorage.getItem('ielts_sim_session'); - if (!raw) return null; - const snapshot = JSON.parse(raw); + const snapshot = global.AppData.recovery.windowSession.get('simulation'); if (!snapshot || !snapshot.id || !Array.isArray(snapshot.sequence)) return null; return snapshot; } catch (_) { return null; } @@ -1188,9 +1289,7 @@ _clearSessionStorage() { try { - if (global.sessionStorage) { - global.sessionStorage.removeItem('ielts_sim_session'); - } + global.AppData.recovery.windowSession.discard('simulation'); } catch (_) { /* ignore */ } }, @@ -1300,8 +1399,6 @@ const pausedAtMs = Number.isFinite(Number(session.suiteTimerPausedAtMs)) ? Number(session.suiteTimerPausedAtMs) : null; const suiteTimerRunning = session.suiteTimerRunning !== false; const payload = { - type: 'SIMULATION_CONTEXT', - data: { suiteSessionId: session.id, flowMode: session.flowMode || 'simulation', examId, @@ -1330,10 +1427,9 @@ pausedAtMs, running: suiteTimerRunning } - } }; try { - targetWindow.postMessage(payload, '*'); + this._postExamMessage(examId, targetWindow, 'SIMULATION_CONTEXT', payload); return true; } catch (e) { console.warn('[SuitePractice] 发送模拟上下文失败:', e); @@ -1345,7 +1441,16 @@ const session = this.currentSuiteSession; if (!session || session.status !== 'active') return false; if (session.flowMode !== 'simulation') return false; - if (session.simulationNavigateLocked === true) return false; + if (session.simulationNavigateLocked === true) { + const inFlight = this._simulationNavigateInFlight; + if (!inFlight || typeof inFlight.then !== 'function') return false; + try { + await inFlight; + } catch (_) { + // The queued request still gets its own validation and error path. + } + return this._handleSimulationNavigate(examId, data, sourceWindow); + } const normalizedExamId = examId != null ? String(examId).trim() : ''; const activeExamId = session.activeExamId != null ? String(session.activeExamId).trim() : ''; if (!normalizedExamId) return false; @@ -1359,6 +1464,11 @@ } session.activeExamId = normalizedExamId; } + let releaseNavigation; + const navigationInFlight = new Promise((resolve) => { + releaseNavigation = resolve; + }); + this._simulationNavigateInFlight = navigationInFlight; session.simulationNavigateLocked = true; try { @@ -1402,6 +1512,7 @@ session.activeExamId = targetEntry.examId; const targetWindow = await this.openExam(targetEntry.examId, { + examDefinition: targetEntry.exam, target: 'tab', windowName: session.windowName || 'ielts-suite-mode-tab', suiteSessionId: session.id, @@ -1441,6 +1552,10 @@ return true; } finally { session.simulationNavigateLocked = false; + if (this._simulationNavigateInFlight === navigationInFlight) { + this._simulationNavigateInFlight = null; + } + releaseNavigation(); } }, @@ -1461,7 +1576,7 @@ if (!session || (session.status !== 'active' && session.status !== 'initializing') || !examId) { return false; } - if (session.flowMode !== 'simulation') { + if (session.flowMode !== 'simulation' && session.flowMode !== 'stationary') { return false; } if (!Array.isArray(session.sequence) || !session.sequence.length) { @@ -1483,7 +1598,7 @@ const pageType = windowInfo && typeof windowInfo.pageType === 'string' ? windowInfo.pageType.toLowerCase() : ''; - if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) { + if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) { return false; } let targetWindow = session.windowRef && !session.windowRef.closed ? session.windowRef : null; @@ -1528,11 +1643,14 @@ session.activeExamId = examId; session.windowRef = targetWindow; this._mirrorSessionToStorage(session); - if (session._contextSentExamId === examId + if (session.flowMode === 'simulation' && session._contextSentExamId === examId && Number.isFinite(Number(session._contextSentAt)) && Date.now() - session._contextSentAt < 3000) { return true; } + if (session.flowMode === 'stationary') { + return this._sendSuiteReviewState(session, examId, targetWindow); + } return this._sendSimulationContext(session, examId, targetWindow); }, @@ -1560,6 +1678,9 @@ if (alreadyRecorded) { console.warn('[MultiSuite] 套题已记录,跳过:', suiteData.suiteId); + if (session.status !== 'completed' && this.isMultiSuiteComplete(session)) { + return await this.finalizeMultiSuiteRecord(session); + } return true; } @@ -1602,8 +1723,7 @@ // 检查是否所有套题都已完成 if (this.isMultiSuiteComplete(session)) { console.log('[MultiSuite] all suite entries completed, finalizing consolidated record.'); - await this.finalizeMultiSuiteRecord(session); - return true; + return await this.finalizeMultiSuiteRecord(session); } // 还有套题未完成,保存当前进度 @@ -1650,12 +1770,13 @@ async finalizeMultiSuiteRecord(session) { if (!session || !Array.isArray(session.suiteResults) || session.suiteResults.length === 0) { console.warn('[MultiSuite] 无效的会话或无结果,跳过聚合'); - return; + return false; } session.status = 'finalizing'; console.log('[MultiSuite] 开始聚合多套题记录:', session.id); + let record = null; try { const completionTime = Date.now(); const startTime = session.startTime || completionTime; @@ -1685,7 +1806,7 @@ const displayTitle = dateLabel + ' ' + sourceLabel + ' multi-suite practice'; // 构建聚合记录 - const record = { + record = { id: session.id, examId: session.baseExamId, title: displayTitle, @@ -1750,36 +1871,49 @@ // 保存聚合记录 await this._saveSuitePracticeRecord(record); - - // 保存拼写错误到词表 - if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) { - try { - await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors); - console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length); - } catch (error) { - console.warn('[MultiSuite] 保存拼写错误失败:', error); - } + session.status = 'completed'; + } catch (error) { + console.error('[MultiSuite] 聚合记录失败:', error); + session.status = 'error'; + try { + window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error'); + } catch (notificationError) { + console.warn('[MultiSuite] 显示聚合保存失败通知时出错:', notificationError); } + return false; + } - // 更新状态 - await this._updatePracticeRecordsState(); + // From here on the aggregate record is authoritative. Every remaining action is best-effort + // and must not turn the committed submission into a NACK or another persistence attempt. + const aggregatedSpellingErrors = Array.isArray(record.spellingErrors) ? record.spellingErrors : []; + if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) { + await this._runSuitePostCommitStep('保存多套题拼写错误', async () => { + await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors); + console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length); + }); + } + await this._runSuitePostCommitStep('同步多套题练习记录', () => this._updatePracticeRecordsState()); + await this._runSuitePostCommitStep('刷新多套题总览', () => { this.refreshOverviewData && this.refreshOverviewData(); - - // 清理会话 + }); + await this._runSuitePostCommitStep('清理多套题会话', () => { this.multiSuiteSessionsMap.delete(session.baseExamId); - session.status = 'completed'; - + }); + await this._runSuitePostCommitStep('显示多套题完成通知', () => { window.showMessage && window.showMessage('多套题练习已完成,已保存 ' + session.suiteResults.length + ' 条套题记录。', 'success'); + }); + console.log('[MultiSuite] consolidated record saved:', record.id); + return true; + }, - - - console.log('[MultiSuite] consolidated record saved:', record.id); - + async _runSuitePostCommitStep(label, callback) { + try { + await callback(); + return true; } catch (error) { - console.error('[MultiSuite] 聚合记录失败:', error); - session.status = 'error'; - window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error'); + console.warn(`[SuitePractice] ${label}失败(聚合记录已保存):`, error); + return false; } }, @@ -1953,14 +2087,15 @@ return aggregated; }, - async finalizeSuiteRecord(session) { + async finalizeSuiteRecord(session, options = {}) { if (!session || !session.results || !session.results.length) { await this._teardownSuiteSession(session); - return; + return false; } session.status = 'finalizing'; + let committed = false; try { const completionTime = Date.now(); const suiteEntries = session.results.map(entry => { @@ -1976,6 +2111,8 @@ markedQuestions: Array.isArray(entry.markedQuestions) ? entry.markedQuestions.slice() : [], highlights: this._resolveSuiteEntryHighlights(entry, draft), noteText: this._resolveSuiteEntryNoteText(entry, draft), + notes: this._resolveSuiteEntryNotes(entry, draft), + noteOutlines: this._resolveSuiteEntryNoteOutlines(entry, draft), scrollY: this._resolveSuiteEntryScrollY(entry, draft), rawData: this._sanitizeSuiteRawData(entry.rawData) }; @@ -2074,55 +2211,55 @@ }; await this._saveSuitePracticeRecord(record); - await this._updatePracticeRecordsState(); - this.refreshOverviewData && this.refreshOverviewData(); - window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success'); + committed = true; session.status = 'completed'; } catch (error) { console.error('[SuitePractice] 保存套题记录失败:', error); - window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error'); - await this._savePartialSuiteAsIndividual(session); - session.status = 'error'; - } finally { - await this._teardownSuiteSession(session); - } - }, - - async _fetchSuiteExamIndex() { - let list = this.getState ? this.getState('exam.index') : null; - if (!Array.isArray(list) || !list.length) { try { - const activeKey = await storage.get('active_exam_index_key', 'exam_index'); - list = await storage.get(activeKey, []); - if (!Array.isArray(list) || !list.length) { - list = await storage.get('exam_index', []); - } - } catch (error) { - console.warn('[SuitePractice] Failed to load exam index, falling back to the default bank.', error); - list = await storage.get('exam_index', []); + window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error'); + } catch (notificationError) { + console.warn('[SuitePractice] 显示套题保存失败通知时出错:', notificationError); } + try { + await this._savePartialSuiteAsIndividual(session); + } catch (fallbackError) { + console.warn('[SuitePractice] 聚合记录未保存,单篇恢复也失败:', fallbackError); + } + session.status = options.deferTeardown ? 'active' : 'error'; + } + + if (committed) { + await this._runSuitePostCommitStep('同步套题练习记录', () => this._updatePracticeRecordsState()); + await this._runSuitePostCommitStep('刷新套题总览', () => { + this.refreshOverviewData && this.refreshOverviewData(); + }); + await this._runSuitePostCommitStep('显示套题完成通知', () => { + window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success'); + }); } + if (!options.deferTeardown) { + await this._runSuitePostCommitStep('清理套题会话窗口', () => this._teardownSuiteSession(session)); + } + return committed; + }, + + async _fetchSuiteExamIndex() { + const list = await window.resolveActiveLibraryIndex(); return Array.isArray(list) ? list.filter(Boolean) : []; }, async _listPracticeRecordsViaAPI() { const normalizeList = (list) => (Array.isArray(list) ? list : []); - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return normalizeList(await window.PracticeRecordAPI.list()); - } - - return []; + // Filtering needs suiteEntries and suite markers, but never highlights or notes. + // The detail projection contains those fields without loading the annotation layer. + return normalizeList(await window.AppData.practice.list({ projection: 'detail' })); }, async _recalculatePracticeStatsFromRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.recalculateStats === 'function') { - await window.PracticeRecordAPI.recalculateStats(); - return true; - } - console.warn('[SuitePractice] 统一练习统计 API 未就绪'); - return false; + await window.AppData.practice.getStats(); + return true; }, async _loadSuitePracticeRecordsForFiltering() { @@ -2455,6 +2592,7 @@ let examWindow = null; try { examWindow = await this.openExam(firstEntry.examId, { + examDefinition: firstEntry.exam, target: 'tab', windowName: suiteWindowName, suiteSessionId, @@ -2583,13 +2721,26 @@ }, async _saveSuitePracticeRecord(record) { - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.saveRecord !== 'function') { - throw new Error('统一练习记录存储未就绪'); - } - await window.PracticeRecordAPI.saveRecord(record, { updateStats: true }); - await this._cleanupSuiteEntryRecords(record).catch(error => { - console.warn('[SuitePractice] 清理套题子记录失败:', error); + const childSessionIds = []; + (Array.isArray(record && record.suiteEntries) ? record.suiteEntries : []).forEach((entry) => { + const raw = entry && entry.rawData || {}; + const sessionId = raw.sessionId || (entry && (entry.sessionId || entry.suiteEntrySessionId)); + if (sessionId && String(sessionId) !== String(record.sessionId || '')) childSessionIds.push(String(sessionId)); + }); + const receipt = await window.AppData.practice.finalizeSuite({ + record, + childSessionIds, + operationId: record.operationId + || (record.submissionId + ? `practice-suite:${String(record.sessionId || 'session')}:${String(record.submissionId)}` + : undefined) }); + if (!receipt || receipt.committed !== true) { + const error = new Error('Suite aggregate commit was not confirmed'); + error.code = 'SUITE_COMMIT_NOT_CONFIRMED'; + throw error; + } + return receipt.record || record; }, async _cleanupSuiteEntryRecords(record) { @@ -2622,14 +2773,7 @@ return; } - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.deleteMany !== 'function') { - throw new Error('统一练习记录删除 API 未就绪'); - } - const result = await window.PracticeRecordAPI.deleteMany(Array.from(entrySessionIds), { updateStats: true, matchBy: 'sessionId' }); - const deletedCount = Number(result && result.deletedCount) || 0; - if (deletedCount > 0) { - console.log('[SuitePractice] cleared ' + deletedCount + ' suite child records'); - } + // Child cleanup is committed atomically by practice.finalizeSuite. }, async _updatePracticeRecordsState() { @@ -2638,22 +2782,20 @@ await window.syncPracticeRecords({ forceRender: true }); return; } else { - const latest = await this._listPracticeRecordsViaAPI(); - if (this.setState) { - this.setState('practice.records', Array.isArray(latest) ? latest : []); + const [latest, index] = await Promise.all([ + window.AppData.practice.list({ projection: 'light' }), + window.resolveActiveLibraryIndex() + ]); + if (typeof window.refreshBrowseProgressFromRecords === 'function') { + window.refreshBrowseProgressFromRecords(latest, index); + } + if (typeof window.updatePracticeView === 'function') { + window.updatePracticeView(latest, index); } } } catch (error) { console.warn('[SuitePractice] 同步练习记录失败:', error); } - - try { - if (typeof window.updatePracticeView === 'function') { - window.updatePracticeView(); - } - } catch (error) { - console.warn('[SuitePractice] 刷新练习视图失败:', error); - } }, _formatSuiteDateLabel(timestamp) { @@ -2775,16 +2917,21 @@ return; } + if (session.submitReceiptTeardownTimer) { + clearTimeout(session.submitReceiptTeardownTimer); + session.submitReceiptTeardownTimer = null; + } + this._clearSuiteHandshakes(); if (session.windowRef && !session.windowRef.closed && typeof session.windowRef.postMessage === 'function') { try { - session.windowRef.postMessage({ - type: 'SUITE_FORCE_CLOSE', - data: { - suiteSessionId: session.id || null - } - }, '*'); + const activeExamId = session.activeExamId + || (session.sequence && session.sequence[session.currentIndex || 0] && session.sequence[session.currentIndex || 0].examId) + || ''; + this._postExamMessage(activeExamId, session.windowRef, 'SUITE_FORCE_CLOSE', { + suiteSessionId: session.id || null + }); } catch (forceCloseError) { console.warn('[SuitePractice] 无法通知套题窗口关闭:', forceCloseError); } @@ -3175,9 +3322,3 @@ global.ExamSystemAppMixins = global.ExamSystemAppMixins || {}; global.ExamSystemAppMixins.suitePractice = mixin; })(typeof window !== 'undefined' ? window : globalThis); - - - - - - diff --git a/js/components/practiceHistoryEnhancer.js b/js/components/practiceHistoryEnhancer.js index a9793db1..3385cca5 100644 --- a/js/components/practiceHistoryEnhancer.js +++ b/js/components/practiceHistoryEnhancer.js @@ -76,7 +76,7 @@ class PracticeHistoryEnhancer { const hasStandardComponent = window.app?.components?.practiceHistory; const hasBasicStructure = document.querySelector('.practice-history') || document.querySelector('#practice-records') || - window.PracticeRecordAPI; + window.AppData; if (hasStandardComponent || hasBasicStructure) { clearInterval(checkInterval); @@ -265,30 +265,11 @@ class PracticeHistoryEnhancer { */ async exportAsJSON() { try { - let practiceRecords = []; - let practiceStats = {}; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - practiceRecords = Array.isArray(records) ? records : []; - } - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - practiceStats = await window.PracticeRecordAPI.readStats(); - } - - if (practiceRecords.length === 0) { + const practiceRecords = await window.AppData.practice.list({ projection: 'light' }); + if (!Array.isArray(practiceRecords) || practiceRecords.length === 0) { throw new Error('没有练习记录可导出'); } - - const data = { - exportDate: new Date().toISOString(), - stats: practiceStats, - user_stats: practiceStats, - userStats: practiceStats, - records: practiceRecords, - practice_records: practiceRecords - }; + const data = await window.AppData.backups.export({ domains: ['practice'] }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -311,33 +292,16 @@ class PracticeHistoryEnhancer { } /** - * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌 + * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌。 + * 默认 medium 投影:详情答案层,不含 highlights/notes。 + * 回顾模式请用 fetchRecordById(id, { projection: 'full' })。 */ - async fetchRecordById(recordId) { + async fetchRecordById(recordId, options = {}) { const toIdStr = (v) => v == null ? '' : String(v); const targetIdStr = toIdStr(recordId); + const projection = (options && options.projection) || 'detail'; - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getById === 'function') { - try { - const hit = await window.PracticeRecordAPI.getById(targetIdStr); - if (hit) return hit; - } catch (err) { - console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 获取记录失败:', err); - } - } - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - try { - const records = await window.PracticeRecordAPI.list(); - if (!Array.isArray(records)) return null; - const hit = records.find(r => toIdStr(r.id) === targetIdStr || toIdStr(r.sessionId) === targetIdStr); - if (hit) return hit; - } catch (err) { - console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 列表查找记录失败:', err); - } - } - - return null; + return window.AppData.practice.get(targetIdStr, { projection }); } /** diff --git a/js/components/practiceRecordModal.js b/js/components/practiceRecordModal.js index 69e236d1..bcab9afa 100644 --- a/js/components/practiceRecordModal.js +++ b/js/components/practiceRecordModal.js @@ -15,7 +15,8 @@ class PracticeRecordModal { show(record) { try { - const replayRecord = this.cloneRecord(record); + // 详情展示用 medium;回顾时再按 id 拉 full,避免把注解灌进 modal 缓存。 + const displayRecord = record; let processedRecord = record; if (window.DataConsistencyManager) { @@ -29,7 +30,8 @@ class PracticeRecordModal { const modalHtml = this.createModalHtml(processedRecord); this.hide(); - this.currentRecord = replayRecord; + this.currentRecord = this.cloneRecord(displayRecord); + this.currentRecordId = (displayRecord && (displayRecord.id || displayRecord.sessionId)) || null; document.body.insertAdjacentHTML('beforeend', modalHtml); this.modalElement = document.getElementById(this.modalId); @@ -70,6 +72,7 @@ class PracticeRecordModal { this.modalElement = null; this.currentRecord = null; this.isVisible = false; + this.currentRecordId = null; } teardownEventListeners() { @@ -124,8 +127,10 @@ class PracticeRecordModal { if (replayTrigger) { this.replayTriggerElement = replayTrigger; const launchReplay = async () => { - const replayRecord = this.currentRecord; - if (!replayRecord) { + const recordId = this.currentRecordId + || (this.currentRecord && (this.currentRecord.id || this.currentRecord.sessionId)) + || null; + if (!recordId && !this.currentRecord) { if (typeof window.showMessage === 'function') { window.showMessage('未找到可回放记录', 'error'); } @@ -140,6 +145,23 @@ class PracticeRecordModal { closeModal(); try { + // 回顾必须 full:重新按 id 拉取含 highlights/notes 的完整记录。 + // 当前详情多为 medium,full 失败时不得回退 detail(缺注解)。 + let replayRecord = null; + if (window.AppData && recordId) { + replayRecord = await window.AppData.practice.get(recordId, { projection: 'full' }); + } else if (this.currentRecord && ( + Array.isArray(this.currentRecord.highlights) + || Array.isArray(this.currentRecord.notes) + || this.currentRecord.realData + || this.currentRecord.rawData + )) { + // 无 API 时仅允许已是 full 形态的 currentRecord。 + replayRecord = this.currentRecord; + } + if (!replayRecord) { + throw new Error('无法加载完整记录用于回顾'); + } await window.app.openPracticeRecordReplay(replayRecord); } catch (error) { console.error('[PracticeRecordModal] 启动回放失败:', error); @@ -248,12 +270,12 @@ class PracticeRecordModal { `; } - prepareRecordForDisplay(record) { + prepareRecordForDisplay(record, examDefinition = null) { if (!record) { return record; } if (window.AnswerComparisonUtils && typeof window.AnswerComparisonUtils.withEnrichedMetadata === 'function') { - return window.AnswerComparisonUtils.withEnrichedMetadata(record); + return window.AnswerComparisonUtils.withEnrichedMetadata(record, examDefinition); } return record; } @@ -411,7 +433,10 @@ class PracticeRecordModal { if (record.multiSuite === true && entry.scoreInfo) { const correct = entry.scoreInfo.correct || 0; const total = entry.scoreInfo.total || 0; - const percentage = entry.scoreInfo.percentage || 0; + const rawPercentage = Number(entry.scoreInfo.percentage); + const percentage = Number.isFinite(rawPercentage) + ? (Math.round(rawPercentage * 10) / 10).toFixed(1) + : '0.0'; scoreInfo = `
得分: ${correct}/${total} (${percentage}%)
`; } @@ -1133,36 +1158,26 @@ class PracticeRecordModal { try { const normalise = (value) => (value == null ? '' : String(value)); const targetId = normalise(recordId); - const api = window.PracticeRecordAPI || null; let record = null; - if (api && typeof api.getById === 'function') { - record = await api.getById(targetId); - } - - if (!record && api && typeof api.list === 'function') { - const records = await api.list(); - if (Array.isArray(records)) { - record = records.find(r => normalise(r.id) === targetId) || - records.find(r => normalise(r.sessionId) === targetId); - } - } + record = await window.AppData.practice.get(targetId, { projection: 'full' }); if (!record) { throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728'); } const exporter = new MarkdownExporter(); - const examIndex = await window.storage.get('exam_index', []); - const exam = Array.isArray(examIndex) ? examIndex.find(e => e.id === record.examId) : null; + const exam = typeof window.resolveExamForPracticeRecord === 'function' + ? await window.resolveExamForPracticeRecord(record) + : null; const enrichedRecord = this.prepareRecordForDisplay({ ...record, examInfo: exam || {}, - title: exam?.title || record.title || record.examId || '\u672a\u77e5\u9898\u76ee', - category: exam?.category || record.category || '\u672a\u77e5\u5206\u7c7b', - frequency: exam?.frequency || record.frequency || '\u672a\u77e5\u9891\u7387' - }); + title: record.title || record.metadata?.examTitle || exam?.title || record.examId || '\u672a\u77e5\u9898\u76ee', + category: record.category || record.metadata?.category || exam?.category || '\u672a\u77e5\u5206\u7c7b', + frequency: record.frequency || record.metadata?.frequency || exam?.frequency || '\u672a\u77e5\u9891\u7387' + }, exam); const markdown = exporter.generateRecordMarkdown(enrichedRecord); @@ -1195,20 +1210,9 @@ if (!window.practiceRecordModal.showById) { try { const normalise = (value) => (value == null ? '' : String(value)); const targetId = normalise(recordId); - const api = window.PracticeRecordAPI || null; let record = null; - if (api && typeof api.getById === 'function') { - record = await api.getById(targetId); - } - - if (!record && api && typeof api.list === 'function') { - const records = await api.list(); - if (Array.isArray(records)) { - record = records.find(r => normalise(r.id) === targetId) || - records.find(r => normalise(r.sessionId) === targetId); - } - } + record = await window.AppData.practice.get(targetId, { projection: 'detail' }); if (!record) { throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728'); diff --git a/js/core/practiceCore.js b/js/core/practiceCore.js index f95b7491..b2f015ce 100644 --- a/js/core/practiceCore.js +++ b/js/core/practiceCore.js @@ -50,17 +50,6 @@ 'WORKOUT_COMPLETE' ]); - const STORAGE_KEYS = Object.freeze({ - practiceRecords: 'practice_records', - userStats: 'user_stats', - activeSessions: 'active_sessions', - tempPracticeRecords: 'temp_practice_records' - }); - let internalRepositories = null; - // 由 data/index.js 在仓库注入时通过 __installInternalRepositories 第二参数传入, - // 使仓库未注入前的 fallback 路径也能拿到 storage internal token,避免被新保护层拒绝。 - let internalStorageAccess = null; - function isPlainObject(value) { return value && typeof value === 'object' && !Array.isArray(value); } @@ -90,6 +79,47 @@ return clone; } + /** + * Resolve the complete reading-annotation snapshot from canonical and legacy + * locations. Explicit root values win so review edits can replace an older + * realData mirror; the returned object is deep-cloned and safe to persist. + */ + function resolveAnnotationState(recordData = {}, fallbackSources = [], options = {}) { + const root = isPlainObject(recordData) ? recordData : {}; + const rawData = isPlainObject(root.rawData) ? root.rawData : {}; + const realData = isPlainObject(root.realData) ? root.realData : {}; + const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; + const sources = [root, rawData, realData, rawRealData] + .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources]) + .filter((source) => isPlainObject(source)); + + const pickArray = (field) => { + const source = sources.find((candidate) => ( + Array.isArray(candidate[field]) + && (!options.preferNonEmptyArrays || candidate[field].length) + )) || sources.find((candidate) => Array.isArray(candidate[field])); + return source ? clonePlainObject(source[field]) : []; + }; + const pickString = (field) => { + const source = sources.find((candidate) => typeof candidate[field] === 'string'); + return source ? source[field] : ''; + }; + const scrollSource = sources.find((candidate) => ( + candidate.scrollY !== undefined + && candidate.scrollY !== null + && Number.isFinite(Number(candidate.scrollY)) + )); + + return { + highlights: pickArray('highlights'), + markedQuestions: pickArray('markedQuestions'), + noteText: pickString('noteText'), + notes: pickArray('notes'), + noteOutlines: pickArray('noteOutlines'), + scrollY: scrollSource ? Number(scrollSource.scrollY) : 0 + }; + } + function ensureNumber(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; @@ -672,12 +702,15 @@ : Number(scoreInfo.percentage); scoreInfo.answerKeyComplete = hasCompleteCanonicalCorrectAnswers; + const annotations = resolveAnnotationState(entry); + return { answers, correctAnswers: correctAnswerMap, correctAnswerMap, answerComparison, - scoreInfo + scoreInfo, + ...annotations }; } @@ -895,12 +928,13 @@ } return map; }, {}); - const highlights = Array.isArray(entry.highlights) - ? entry.highlights.slice() - : (Array.isArray(entry.rawData && entry.rawData.highlights) ? entry.rawData.highlights.slice() : []); - const scrollY = Number.isFinite(Number(entry.scrollY)) - ? Number(entry.scrollY) - : (Number.isFinite(Number(entry.rawData && entry.rawData.scrollY)) ? Number(entry.rawData.scrollY) : 0); + // 旧/导入的套题条目可能只在 entry.metadata.markedQuestions 保留标记题, + // 与顶层 standardizeRecord(见下方 resolveAnnotationState(recordData, [recordData.metadata])) + // 保持一致,将 entry.metadata 作为兜底来源传入,避免根级 markedQuestions: [] 被回放 + // 逻辑视作权威而丢弃已保存的标记题。 + const metadata = entry.metadata ? Object.assign({}, entry.metadata) : {}; + const annotations = resolveAnnotationState(entry, [entry.metadata], { preferNonEmptyArrays: true }); + metadata.markedQuestions = clonePlainObject(annotations.markedQuestions); return { examId: entry.examId || null, title: entry.title || entry.examTitle || `套题第${index + 1}篇`, @@ -910,9 +944,8 @@ answers: answerMap, correctAnswerMap: entryCorrectMap, answerComparison: clonePlainObject(answerComparisonSource) || null, - metadata: entry.metadata ? Object.assign({}, entry.metadata) : {}, - highlights, - scrollY, + metadata, + ...annotations, rawData: entry.rawData ? clonePlainObject(entry.rawData) : null }; }).filter(Boolean); @@ -1076,6 +1109,8 @@ ? clonePlainObject(comparisonSource) : null; const realDataCorrectAnswers = clonePlainObject(normalizedCorrectMap || {}); + const annotations = resolveAnnotationState(recordData, [recordData.metadata]); + metadata.markedQuestions = clonePlainObject(annotations.markedQuestions); const generateRecordId = typeof options.generateRecordId === 'function' ? options.generateRecordId : defaultGenerateRecordId; @@ -1104,13 +1139,13 @@ suiteMode: Boolean(recordData.suiteMode || ((recordData.frequency || metadata.frequency || '').toLowerCase() === 'suite')), suiteSessionId: recordData.suiteSessionId || (metadata && metadata.suiteSessionId) || null, suiteEntries: normalizedSuiteEntries, + ...annotations, scoreInfo: recordData.scoreInfo ? Object.assign({}, recordData.scoreInfo, { details: recordData.scoreInfo.details || detailSource || null }) : (detailSource ? { details: detailSource } : null), - realData: recordData.realData - ? Object.assign({}, recordData.realData, { + realData: Object.assign({}, recordData.realData || {}, { answers: (recordData.realData && recordData.realData.answers) || answerMap, correctAnswers: realDataCorrectAnswers, correctAnswerMap: clonePlainObject(normalizedCorrectMap || {}), @@ -1119,9 +1154,9 @@ }), answerComparison: (recordData.realData && recordData.realData.answerComparison) ? clonePlainObject(recordData.realData.answerComparison) - : (normalizedComparison || null) - }) - : (normalizedComparison ? { answerComparison: normalizedComparison } : null), + : (normalizedComparison || null), + ...clonePlainObject(annotations) + }), answerComparison: normalizedComparison, version: options.currentVersion || recordData.version || '0.6.2-fix', createdAt: firstDateCandidate(recordData.createdAt, recordData.startTime, recordData.start_time, recordDate) || now, @@ -1336,26 +1371,7 @@ || (examEntry && examEntry.title) || resolvedExamId || '未命名练习'; - const resolvedHighlights = Array.isArray(rawPayload.highlights) - ? rawPayload.highlights.slice() - : (Array.isArray(rawPayload.realData && rawPayload.realData.highlights) - ? rawPayload.realData.highlights.slice() - : (Array.isArray(sessionContext.highlights) ? sessionContext.highlights.slice() : [])); - const resolvedMarkedQuestions = Array.isArray(rawPayload.markedQuestions) - ? rawPayload.markedQuestions.slice() - : (Array.isArray(rawPayload.realData && rawPayload.realData.markedQuestions) - ? rawPayload.realData.markedQuestions.slice() - : (Array.isArray(sessionContext.markedQuestions) ? sessionContext.markedQuestions.slice() : [])); - const resolvedScrollY = Number.isFinite(Number(rawPayload.scrollY)) - ? Number(rawPayload.scrollY) - : (Number.isFinite(Number(rawPayload.realData && rawPayload.realData.scrollY)) - ? Number(rawPayload.realData.scrollY) - : (Number.isFinite(Number(sessionContext.scrollY)) ? Number(sessionContext.scrollY) : 0)); - const resolvedNoteText = typeof rawPayload.noteText === 'string' - ? rawPayload.noteText - : (typeof rawPayload.realData?.noteText === 'string' - ? rawPayload.realData.noteText - : (typeof sessionContext.noteText === 'string' ? sessionContext.noteText : '')); + const annotations = resolveAnnotationState(rawPayload, [sessionContext]); const resolvedQuestionTypeMap = isPlainObject(rawPayload.questionTypeMap) ? clonePlainObject(rawPayload.questionTypeMap) : (isPlainObject(rawPayload.realData && rawPayload.realData.questionTypeMap) @@ -1389,16 +1405,13 @@ examTitle: title, category, frequency, - markedQuestions: resolvedMarkedQuestions.slice() + markedQuestions: clonePlainObject(annotations.markedQuestions) }), frequency, suiteMode: Boolean(rawPayload.suiteMode || (String(rawPayload.practiceMode || metadata.practiceMode || '').toLowerCase() === 'suite')), suiteSessionId, suiteEntries, - highlights: resolvedHighlights.slice(), - scrollY: resolvedScrollY, - markedQuestions: resolvedMarkedQuestions.slice(), - noteText: resolvedNoteText, + ...annotations, questionTypeMap: resolvedQuestionTypeMap, scoreInfo: Object.assign({}, scoreInfo, { correct: correctAnswers, @@ -1413,10 +1426,7 @@ correctAnswers: correctAnswerMap, answerComparison, correctAnswerMap, - highlights: resolvedHighlights.slice(), - scrollY: resolvedScrollY, - markedQuestions: resolvedMarkedQuestions.slice(), - noteText: resolvedNoteText, + ...clonePlainObject(annotations), questionTypeMap: resolvedQuestionTypeMap, scoreInfo: Object.assign({}, (rawPayload.realData && rawPayload.realData.scoreInfo) || scoreInfo, { correct: correctAnswers, @@ -1434,367 +1444,6 @@ }, options); } - function getRepositories() { - return internalRepositories; - } - - function getStorageManager(storageManager) { - return storageManager || global.persistentStore || global.storage || null; - } - - function getStorageInternalOptions(storage) { - // 仓库注入后用 token 化选项,确保 fallback 读写能通过 storage 的 internal-only 保护。 - if (internalStorageAccess && typeof internalStorageAccess.createInternalOptions === 'function') { - try { - return internalStorageAccess.createInternalOptions({}); - } catch (_) { - // fallthrough 到旧行为 - } - } - return { skipPracticeCoreRedirect: true }; - } - - function syncPracticeRecordState(records) { - const syncAppState = (nextRecords) => { - try { - if (global.app && global.app.state && global.app.state.practice) { - global.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : []; - } - } catch (_) {} - }; - - if (typeof global.setPracticeRecordsState === 'function') { - try { - const finalRecords = global.setPracticeRecordsState(records); - syncAppState(finalRecords); - return; - } catch (error) { - console.warn('[PracticeCore] 同步 practice records 状态失败:', error); - } - } - syncAppState(records); - } - - async function readPracticeRecords(storageManager) { - const repos = getRepositories(); - if (repos && repos.practice && typeof repos.practice.list === 'function') { - return await repos.practice.list(); - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.readPersistentValue === 'function') { - return await storage.readPersistentValue(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage)); - } - if (storage && typeof storage.get === 'function') { - return await storage.get(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage)); - } - return []; - } - - /** - * 轻量投影:读取原始数组(clone:false 跳过 structuredClone),映射为精简 summary 对象。 - * 排除 answers/answerDetails/correctAnswerMap/suiteEntries[]/realData/answerComparison 等重字段, - * 供练习历史列表、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用, - * 避免大数据量下反序列化+克隆全部记录导致的前端渲染卡顿和内存溢出。 - */ - function projectRecordSummary(record) { - if (!record || typeof record !== 'object') { - return null; - } - const scoreInfo = record.scoreInfo || {}; - const metadata = record.metadata || {}; - // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData - const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : []; - const suiteEntries = rawSuiteEntries.map(function (entry) { - if (!entry || typeof entry !== 'object') { return null; } - const entryMeta = entry.metadata || {}; - const entryScore = entry.scoreInfo || {}; - return { - id: entry.id || '', - examId: entry.examId || entryMeta.examId || '', - title: entry.title || entryMeta.examTitle || '', - percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0, - duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0 - }; - }).filter(Boolean); - return { - id: record.id || record.sessionId || '', - sessionId: record.sessionId || null, - examId: record.examId || metadata.examId || null, - title: record.title || metadata.examTitle || '', - type: record.type || metadata.type || 'reading', - practiceType: record.practiceType || metadata.practiceType || metadata.examType || null, - url: record.url || metadata.url || null, - startTime: record.startTime || null, - endTime: record.endTime || null, - date: record.date || null, - duration: Number(record.duration ?? scoreInfo.duration ?? scoreInfo.timeSpent) || 0, - percentage: Number(record.percentage ?? scoreInfo.percentage) || 0, - accuracy: Number(record.accuracy ?? scoreInfo.accuracy) || 0, - score: Number(record.score ?? scoreInfo.score) || 0, - totalQuestions: Number(record.totalQuestions ?? scoreInfo.total) || 0, - correctAnswers: Number(record.correctAnswers ?? scoreInfo.correct) || 0, - status: record.status || 'completed', - suiteMode: Boolean(record.suiteMode), - suiteEntryCount: rawSuiteEntries.length, - suiteEntries: suiteEntries, - suiteSessionId: record.suiteSessionId || (metadata.suiteSessionId) || null, - // questionTypePerformance 是小对象(每题型 {total,correct}),不是重字段,保留供 recalculateStats 使用 - questionTypePerformance: record.questionTypePerformance || null, - // 轻量 scoreInfo 子集:供 accuracy/duration 等 fallback 读取 - scoreInfo: { - accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null, - duration: scoreInfo.duration != null ? scoreInfo.duration : null, - timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null, - percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null, - score: scoreInfo.score != null ? scoreInfo.score : null, - total: scoreInfo.total != null ? scoreInfo.total : null, - correct: scoreInfo.correct != null ? scoreInfo.correct : null - }, - metadata: { - category: metadata.category || record.category || null, - examTitle: metadata.examTitle || record.title || '', - frequency: metadata.frequency || record.frequency || 'unknown', - type: metadata.type || record.type || null, - examType: metadata.examType || null, - practiceType: metadata.practiceType || null, - examId: metadata.examId || null, - title: metadata.title || null, - url: metadata.url || null - }, - updatedAt: record.updatedAt || null, - createdAt: record.createdAt || null - }; - } - - async function readPracticeRecordSummaries(storageManager) { - const repos = getRepositories(); - let records; - if (repos && repos.practice && typeof repos.practice.read === 'function') { - // clone:false 跳过 structuredClone,在投影后原始重字段不会进入返回值 - records = await repos.practice.read({ clone: false }); - } else { - records = await readPracticeRecords(storageManager); - } - if (!Array.isArray(records)) { - return []; - } - return records - .map(projectRecordSummary) - .filter(Boolean); - } - - /** - * 轻量计数:使用 repository.count()(clone:false + .length),不构造 summary 数组。 - */ - async function countPracticeRecords(storageManager) { - const repos = getRepositories(); - if (repos && repos.practice && typeof repos.practice.count === 'function') { - return await repos.practice.count(); - } - const records = await readPracticeRecords(storageManager); - return Array.isArray(records) ? records.length : 0; - } - - async function writePracticeRecords(records, storageManager) { - const finalRecords = Array.isArray(records) ? records : []; - const repos = getRepositories(); - if (repos && repos.practice && typeof repos.practice.overwrite === 'function') { - await repos.practice.overwrite(finalRecords); - syncPracticeRecordState(finalRecords); - return true; - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.writePersistentValue === 'function') { - const result = await storage.writePersistentValue(STORAGE_KEYS.practiceRecords, finalRecords, getStorageInternalOptions(storage)); - syncPracticeRecordState(finalRecords); - return result; - } - return false; - } - - async function readMeta(key, defaultValue, storageManager) { - const repos = getRepositories(); - if (repos && repos.meta && typeof repos.meta.get === 'function') { - return await repos.meta.get(key, defaultValue); - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.readPersistentValue === 'function') { - return await storage.readPersistentValue(key, defaultValue, getStorageInternalOptions(storage)); - } - if (storage && typeof storage.get === 'function') { - return await storage.get(key, defaultValue, getStorageInternalOptions(storage)); - } - return defaultValue; - } - - async function writeMeta(key, value, storageManager) { - const repos = getRepositories(); - if (repos && repos.meta && typeof repos.meta.set === 'function') { - await repos.meta.set(key, value); - return true; - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.writePersistentValue === 'function') { - return await storage.writePersistentValue(key, value, getStorageInternalOptions(storage)); - } - return false; - } - - async function removeMeta(key, storageManager) { - const repos = getRepositories(); - if (repos && repos.meta && typeof repos.meta.remove === 'function') { - await repos.meta.remove(key); - return true; - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.removePersistentValue === 'function') { - return await storage.removePersistentValue(key, getStorageInternalOptions(storage)); - } - return false; - } - - function extractSessionId(record) { - if (!record || typeof record !== 'object') { - return null; - } - const rawId = record.sessionId - || (record.realData && record.realData.sessionId) - || (record.metadata && record.metadata.sessionId) - || null; - if (!rawId) return null; - return String(rawId).trim() || null; - } - - function dedupePracticeRecords(records) { - // 仅按 record.id 去重,不按 sessionId 全局去重。 - // sessionId 在套题场景中是容器标识,不是 attempt 唯一键; - // 多条不同 id 的记录可能共享同一 sessionId(如同一套题的不同 passage), - // 按 sessionId 去重会永久丢弃合法记录。 - const seenIds = new Set(); - const deduped = []; - - (Array.isArray(records) ? records : []).forEach((record) => { - if (!record || typeof record !== 'object') { - return; - } - const recordId = record.id != null ? String(record.id) : null; - - if (recordId && seenIds.has(recordId)) { - return; - } - - if (recordId) seenIds.add(recordId); - deduped.push(record); - }); - - return deduped; - } - - function getRecordTimestamp(record) { - if (!record || typeof record !== 'object') { - return 0; - } - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.date, - record.timestamp - ]; - for (let index = 0; index < candidates.length; index += 1) { - const value = candidates[index]; - if (!value) { - continue; - } - const time = new Date(value).getTime(); - if (Number.isFinite(time)) { - return time; - } - } - return 0; - } - - function handlesStorageKey(key) { - return key === STORAGE_KEYS.practiceRecords - || key === STORAGE_KEYS.userStats - || key === STORAGE_KEYS.activeSessions - || key === STORAGE_KEYS.tempPracticeRecords; - } - - async function replacePracticeRecords(records, options = {}) { - const canonical = dedupePracticeRecords( - (Array.isArray(records) ? records : []).map((record) => standardizeRecord(record, options)) - ); - canonical.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a)); - if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && canonical.length > options.maxRecords) { - canonical.splice(options.maxRecords); - } - return await writePracticeRecords(canonical, options.storageManager); - } - - async function savePracticeRecord(record, options = {}) { - const standardizedRecord = standardizeRecord(record, options); - let records = await readPracticeRecords(options.storageManager); - records = Array.isArray(records) ? records.slice() : []; - - const existingIndex = records.findIndex((entry) => entry && String(entry.id) === String(standardizedRecord.id)); - if (existingIndex >= 0) { - records[existingIndex] = standardizedRecord; - } else { - records.unshift(standardizedRecord); - } - - // 仅当同一 sessionId 且同一 examId 时才移除旧记录(同一篇练习的重复提交覆盖)。 - // 不同 examId 但共享 sessionId 的记录(如套题不同 passage)必须保留。 - const standardizedSessionId = extractSessionId(standardizedRecord); - const standardizedExamId = standardizedRecord.examId || null; - if (standardizedSessionId) { - records = records.filter((entry, index) => { - if (index === 0) { - return true; - } - const sessionId = extractSessionId(entry); - const examId = entry && entry.examId || null; - const sameSession = sessionId && sessionId === standardizedSessionId; - const sameExam = standardizedExamId && examId && examId === standardizedExamId; - return !(sameSession && sameExam && String(entry.id) !== String(standardizedRecord.id)); - }); - } - - records = dedupePracticeRecords(records); - records.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a)); - if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && records.length > options.maxRecords) { - records.splice(options.maxRecords); - } - await writePracticeRecords(records, options.storageManager); - return standardizedRecord; - } - - async function routeStorageSet(storageManager, key, value, options = {}) { - if (key === STORAGE_KEYS.practiceRecords) { - return await replacePracticeRecords(value, { - currentVersion: options.currentVersion || '0.6.2-fix', - maxRecords: options.maxRecords || 1000, - storageManager - }); - } - if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) { - return await writeMeta(key, value, storageManager); - } - return null; - } - - async function routeStorageRemove(storageManager, key) { - if (key === STORAGE_KEYS.practiceRecords) { - return await writePracticeRecords([], storageManager); - } - if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) { - return await removeMeta(key, storageManager); - } - return null; - } - const contracts = Object.freeze({ ensureNumber, normalizePracticeType, @@ -1823,6 +1472,7 @@ buildMetadata, standardizeRecord, standardizeSuiteEntries, + resolveAnnotationState, clonePlainObject }); @@ -1839,71 +1489,12 @@ fromCompletion }); - const internalStore = Object.freeze({ - STORAGE_KEYS, - handlesStorageKey, - listPracticeRecords: readPracticeRecords, - listPracticeRecordSummaries: readPracticeRecordSummaries, - countPracticeRecords, - replacePracticeRecords, - savePracticeRecord, - routeStorageSet, - routeStorageRemove, - readMeta, - writeMeta, - removeMeta, - syncPracticeRecordState - }); - - const publicStore = Object.freeze({ - STORAGE_KEYS, - handlesStorageKey, - listPracticeRecords: readPracticeRecords, - listPracticeRecordSummaries: readPracticeRecordSummaries, - countPracticeRecords, - readMeta, - syncPracticeRecordState - }); - - const practiceCore = { + const practiceCore = Object.freeze({ __stable: true, version: '0.6.2-fix', contracts, protocol, - ingestor, - store: publicStore - }; - Object.defineProperty(practiceCore, '__installRecordAPI', { - value: function(install) { - if (typeof install !== 'function') { - throw new Error('PracticeCore.__installRecordAPI requires an installer function'); - } - return install(internalStore); - }, - enumerable: false, - configurable: true, - writable: false - }); - Object.defineProperty(practiceCore, '__installInternalRepositories', { - value: function(repositories, installers) { - if (!repositories || typeof repositories !== 'object') { - throw new Error('PracticeCore.__installInternalRepositories requires repositories'); - } - internalRepositories = repositories; - // 接收 storage internal token factory,供 fallback 路径使用。 - if (installers && typeof installers.createInternalOptions === 'function') { - internalStorageAccess = { createInternalOptions: installers.createInternalOptions }; - } - try { - delete practiceCore.__installInternalRepositories; - } catch (_) { - practiceCore.__installInternalRepositories = undefined; - } - return true; - }, - enumerable: false, - configurable: true, - writable: false + ingestor }); global.PracticeCore = practiceCore; })(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/core/practiceRecorder.js b/js/core/practiceRecorder.js index 6cdfb31b..49433082 100644 --- a/js/core/practiceRecorder.js +++ b/js/core/practiceRecorder.js @@ -1,5 +1,3 @@ -const PRACTICE_RECORDER_EXPORT_VERSION = '0.6.2-fix'; - /** * 练习记录管理器 * 负责练习会话管理、成绩记录和数据持久化 @@ -11,19 +9,11 @@ class PracticeRecorder { this.autoSaveInterval = 30000; // 30秒自动保存 this.autoSaveTimer = null; - // 初始化存储系统 - this.scoreStorage = new ScoreStorage(); - this.repositories = window.dataRepositories; - if (!this.repositories) { - throw new Error('数据仓库未初始化,PracticeRecorder 无法构建'); - } - this.metaRepo = this.repositories.meta; - this.practiceTypeCache = new Map(); // 异步初始化 this.ready = (async () => { - await this.scoreStorage.ready; + await window.AppData.ready; await this.initialize(); })(); @@ -58,6 +48,72 @@ class PracticeRecorder { throw new Error(`PracticeRecorder requires PracticeCore.contracts.${name}`); } + clonePlainObject(value) { + const coreContracts = this.getCoreContracts(); + if (coreContracts && typeof coreContracts.clonePlainObject === 'function') { + return coreContracts.clonePlainObject(value); + } + if (value == null || typeof value !== 'object') { + return value ?? null; + } + if (Array.isArray(value)) { + return value.map((item) => this.clonePlainObject(item)); + } + const clone = {}; + Object.keys(value).forEach((key) => { + clone[key] = this.clonePlainObject(value[key]); + }); + return clone; + } + + activeSessionEntityId(sessionOrId) { + const rawId = sessionOrId && typeof sessionOrId === 'object' + ? (sessionOrId.id || sessionOrId.sessionId) + : sessionOrId; + const normalized = String(rawId || '').trim(); + if (!normalized) { + throw new Error('Active practice session requires a stable session id'); + } + return normalized.startsWith('active-session:') ? normalized : `active-session:${normalized}`; + } + + async persistActiveSession(session, previousEntityId = null) { + const entity = Object.assign({}, session, { id: this.activeSessionEntityId(session) }); + const receipt = await window.AppData.recovery.saveActiveSession(entity); + if (previousEntityId && previousEntityId !== entity.id) { + await window.AppData.recovery.discardActiveSession(previousEntityId); + } + return receipt; + } + + resolveAnnotationState(recordData = {}, fallbackSources = []) { + const coreContracts = this.getCoreContracts(); + if (coreContracts && typeof coreContracts.resolveAnnotationState === 'function') { + return coreContracts.resolveAnnotationState(recordData, fallbackSources); + } + const root = recordData && typeof recordData === 'object' ? recordData : {}; + const sources = [root, root.rawData, root.realData, root.rawData?.realData] + .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources]) + .filter((source) => source && typeof source === 'object' && !Array.isArray(source)); + const pickArray = (field) => { + const source = sources.find((candidate) => Array.isArray(candidate[field])); + return source ? this.clonePlainObject(source[field]) : []; + }; + const pickString = (field) => { + const source = sources.find((candidate) => typeof candidate[field] === 'string'); + return source ? source[field] : ''; + }; + const scrollSource = sources.find((candidate) => candidate.scrollY != null && Number.isFinite(Number(candidate.scrollY))); + return { + highlights: pickArray('highlights'), + markedQuestions: pickArray('markedQuestions'), + noteText: pickString('noteText'), + notes: pickArray('notes'), + noteOutlines: pickArray('noteOutlines'), + scrollY: scrollSource ? Number(scrollSource.scrollY) : 0 + }; + } + firstFiniteNumber(fallback, ...values) { for (const value of values) { if (value === undefined || value === null) { @@ -108,8 +164,6 @@ class PracticeRecorder { async recordRejectedCompletionPayload(payload, context = {}) { try { - const existing = await this.metaRepo.get('rejected_completion_payloads', []); - const list = Array.isArray(existing) ? existing : []; const snapshot = { id: `rejected_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, createdAt: new Date().toISOString(), @@ -126,54 +180,33 @@ class PracticeRecorder { } : null }; - list.unshift(snapshot); - if (list.length > 50) { - list.splice(50); + await window.AppData.recovery.saveRejectedCompletion(snapshot); + const existing = await window.AppData.recovery.listRejectedCompletions(); + const list = (Array.isArray(existing) ? existing : []) + .slice() + .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0)); + for (const stale of list.slice(50)) { + await window.AppData.recovery.discardRejectedCompletion(stale.id || stale.sessionId || stale.recordId); } - await this.metaRepo.set('rejected_completion_payloads', list); } catch (error) { console.warn('[PracticeRecorder] 记录拒绝的完成负载失败:', error); } } - lookupExamIndexEntry(examId) { + lookupExamIndexEntry(examId, examIndex = []) { if (!examId) return null; - if (this.practiceTypeCache.has(examId)) { - return this.practiceTypeCache.get(examId); - } - - const sources = [ - () => Array.isArray(window.examIndex) ? window.examIndex : null, - () => typeof window.getReadingExamIndex === 'function' - ? window.getReadingExamIndex().map(exam => ({ ...exam, type: exam.type || 'reading' })) - : null, - () => Array.isArray(window.__READING_EXAM_INDEX__) - ? window.__READING_EXAM_INDEX__.map(exam => ({ ...exam, type: exam.type || 'reading' })) - : null, - () => Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : null - ]; - - for (const getSource of sources) { - const list = getSource(); - if (Array.isArray(list)) { - const entry = list.find(item => item && item.id === examId); - if (entry) { - this.practiceTypeCache.set(examId, entry); - return entry; - } - } - } - - this.practiceTypeCache.set(examId, null); - return null; + const entry = (Array.isArray(examIndex) ? examIndex : []) + .find(item => item && item.id === examId) || null; + if (entry) this.practiceTypeCache.set(examId, entry); + return entry; } resolvePracticeType(session = {}, examEntry = null) { const examId = session.examId; const metadata = session.metadata || {}; const cachedEntry = this.practiceTypeCache.get(examId); - const entry = examEntry || cachedEntry || this.lookupExamIndexEntry(examId); + const entry = examEntry || cachedEntry || null; const normalized = this.normalizePracticeType( metadata.type @@ -329,12 +362,16 @@ class PracticeRecorder { * 恢复活动会话 */ async restoreActiveSessions() { - const raw = await this.metaRepo.get('active_sessions', []); + const raw = await window.AppData.recovery.listActiveSessions(); const storedSessions = Array.isArray(raw) ? raw : []; - storedSessions.forEach(sessionData => { + storedSessions + .slice() + .sort((left, right) => Date.parse(left.updatedAt || left.lastActivity || 0) - Date.parse(right.updatedAt || right.lastActivity || 0)) + .forEach(sessionData => { this.activeSessions.set(sessionData.examId, { ...sessionData, + id: this.activeSessionEntityId(sessionData), status: 'restored', lastActivity: new Date().toISOString() }); @@ -370,6 +407,13 @@ class PracticeRecorder { } const { type, data } = normalized; + // Completion persistence belongs exclusively to the exam host protocol. The + // recorder is invoked there only after source/origin/token validation, so a + // second global listener must never race it into a duplicate save. + if (type === 'session_completed') { + return; + } + switch (type) { case 'session_started': this.handleSessionStarted(data); @@ -377,11 +421,6 @@ class PracticeRecorder { case 'session_progress': this.handleSessionProgress(data); break; - case 'session_completed': - this.handleSessionCompleted(data).catch(error => { - console.error('[PracticeRecorder] 会话完成处理失败:', error); - }); - break; case 'session_paused': this.handleSessionPaused(data); break; @@ -483,18 +522,7 @@ class PracticeRecorder { normalizedComparison ); const answerList = this.convertAnswerMapToArray(answerMap, correctAnswerMap); - const highlights = Array.isArray(payload.highlights) - ? payload.highlights.slice() - : (Array.isArray(payload.realData?.highlights) ? payload.realData.highlights.slice() : []); - const markedQuestions = Array.isArray(payload.markedQuestions) - ? payload.markedQuestions.slice() - : (Array.isArray(payload.realData?.markedQuestions) ? payload.realData.markedQuestions.slice() : []); - const scrollY = Number.isFinite(Number(payload.scrollY)) - ? Number(payload.scrollY) - : (Number.isFinite(Number(payload.realData?.scrollY)) ? Number(payload.realData.scrollY) : 0); - const noteText = typeof payload.noteText === 'string' - ? payload.noteText - : (typeof payload.realData?.noteText === 'string' ? payload.realData.noteText : ''); + const annotations = this.resolveAnnotationState(payload); const questionTypeMap = payload.questionTypeMap && typeof payload.questionTypeMap === 'object' ? { ...payload.questionTypeMap } : (payload.realData?.questionTypeMap && typeof payload.realData.questionTypeMap === 'object' @@ -552,15 +580,12 @@ class PracticeRecorder { answerComparison: normalizedComparison, questionTypePerformance: payload.questionTypePerformance || {}, interactions: payload.interactions || [], - highlights, - scrollY, - markedQuestions, - noteText, + ...annotations, questionTypeMap, startTime: payload.startTime || null, endTime: payload.endTime || null, metadata: Object.assign({}, payload.metadata || {}, { - markedQuestions: markedQuestions.slice() + markedQuestions: this.clonePlainObject(annotations.markedQuestions) }), source: scoreInfo.source || payload.pageType || 'practice_page', realData: Object.assign({}, payload.realData || {}, { @@ -568,10 +593,7 @@ class PracticeRecorder { correctAnswers: correctAnswerMap, correctAnswerMap, answerComparison: normalizedComparison, - highlights, - scrollY, - markedQuestions, - noteText, + ...this.clonePlainObject(annotations), questionTypeMap, scoreInfo: Object.assign({}, scoreInfo, { details: answerDetails }) }) @@ -689,35 +711,61 @@ class PracticeRecorder { * 开始练习会话 */ startPracticeSession(examId, examData = {}) { - const sessionId = this.generateSessionId(); - const startTime = new Date().toISOString(); + const requestedSessionId = examData && examData.sessionId != null + ? String(examData.sessionId).trim() + : ''; + const existing = this.activeSessions.has(examId) + ? this.activeSessions.get(examId) + : null; + // Prefer an explicit host session id so INIT/COMPLETE and the recorder share one + // identity. Reuse an existing active session when the host rebinds the same exam. + const sessionId = requestedSessionId + || (existing && existing.sessionId) + || this.generateSessionId(examId); + const startTime = (existing && existing.startTime) + || new Date().toISOString(); + const previousEntityId = existing + ? this.activeSessionEntityId(existing) + : null; const sessionData = { + id: this.activeSessionEntityId(sessionId), sessionId, examId, startTime, - lastActivity: startTime, - status: 'started', - progress: { + lastActivity: new Date().toISOString(), + status: existing ? (existing.status || 'started') : 'started', + progress: Object.assign({ currentQuestion: 0, totalQuestions: examData.totalQuestions || 0, answeredQuestions: 0, timeSpent: 0 - }, - answers: [], - metadata: { + }, existing && existing.progress ? existing.progress : {}), + answers: existing && existing.answers ? existing.answers : [], + metadata: Object.assign({ examTitle: examData.title || '', category: examData.category || '', frequency: examData.frequency || '', userAgent: navigator.userAgent, screenResolution: `${screen.width}x${screen.height}`, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone - } + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + // 启动时捕获的题库配置 ID:mixin/调用方传入则写进会话 metadata,后续经 + // handleSessionCompleted 的 buildRecordMetadata 透传到记录 metadata。 + libraryConfigurationId: (examData && examData.libraryConfigurationId != null) + ? examData.libraryConfigurationId + : null + }, existing && existing.metadata ? existing.metadata : {}) }; + if (examData && examData.libraryConfigurationId != null) { + sessionData.metadata.libraryConfigurationId = examData.libraryConfigurationId; + } + if (examData && examData.title) { + sessionData.metadata.examTitle = examData.title; + } // 存储会话 this.activeSessions.set(examId, sessionData); - this.saveActiveSessions().catch(error => { + this.persistActiveSession(sessionData, previousEntityId).catch(error => { console.error('[PracticeRecorder] 保存活动会话失败:', error); }); @@ -736,25 +784,55 @@ class PracticeRecorder { * 处理会话开始 */ handleSessionStarted(data) { - const { examId, sessionId, metadata } = data; + const examId = data && data.examId != null ? String(data.examId).trim() : ''; + const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : ''; + const metadata = data && data.metadata && typeof data.metadata === 'object' + ? data.metadata + : null; - if (this.activeSessions.has(examId)) { - let session = this.activeSessions.get(examId); - session.sessionId = sessionId; - session.status = 'active'; - session.lastActivity = new Date().toISOString(); + if (!examId || !sessionId) { + return; + } - if (metadata) { - session.metadata = { ...session.metadata, ...metadata }; + // Host handshake (SESSION_READY / INIT rebind) must create the active session when + // the full PracticeRecorder was hot-upgraded after a fallback start, or when the + // early startPracticeSession raced ahead of the host expectedSessionId. + if (!this.activeSessions.has(examId)) { + this.startPracticeSession(examId, Object.assign({}, metadata || {}, { + sessionId, + title: metadata && (metadata.title || metadata.examTitle) || '', + category: metadata && metadata.category || '', + frequency: metadata && metadata.frequency || '', + libraryConfigurationId: metadata && metadata.libraryConfigurationId != null + ? metadata.libraryConfigurationId + : null + })); + const created = this.activeSessions.get(examId); + if (created) { + created.status = 'active'; + this.activeSessions.set(examId, created); } + console.log(`Session created on host confirm: ${examId}`); + return; + } - this.activeSessions.set(examId, session); - this.saveActiveSessions().catch(error => { - console.error('[PracticeRecorder] 保存活动会话失败:', error); - }); + let session = this.activeSessions.get(examId); + const previousEntityId = this.activeSessionEntityId(session); + session.sessionId = sessionId; + session.id = this.activeSessionEntityId(sessionId); + session.status = 'active'; + session.lastActivity = new Date().toISOString(); - console.log(`Session confirmed started: ${examId}`); + if (metadata) { + session.metadata = { ...session.metadata, ...metadata }; } + + this.activeSessions.set(examId, session); + this.persistActiveSession(session, previousEntityId).catch(error => { + console.error('[PracticeRecorder] 保存活动会话失败:', error); + }); + + console.log(`Session confirmed started: ${examId}`); } /** @@ -792,6 +870,7 @@ class PracticeRecorder { } const { results } = payload; + const examIndex = await window.resolveActiveLibraryIndex(); const candidateExamIds = [ payload.examId, payload.originalExamId, @@ -866,9 +945,9 @@ class PracticeRecorder { session.startTime = resolvedStartTime; - const examEntry = this.lookupExamIndexEntry(resolvedExamId) - || this.lookupExamIndexEntry(payload.originalExamId) - || this.lookupExamIndexEntry(payload.derivedExamId); + const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex) + || this.lookupExamIndexEntry(payload.originalExamId, examIndex) + || this.lookupExamIndexEntry(payload.derivedExamId, examIndex); const type = this.resolvePracticeType({ ...session, examId: resolvedExamId }, examEntry); const recordDate = this.resolveRecordDate({ ...session, endTime: resolvedEndTime }, resolvedEndTime); let metadata = this.buildRecordMetadata( @@ -948,6 +1027,8 @@ class PracticeRecorder { results?.accuracy, scoreInfo.accuracy ); + const annotations = this.resolveAnnotationState(results || {}, [session || {}]); + metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions); const practiceRecord = { id: `record_${session.sessionId || this.generateSessionId(resolvedExamId)}`, @@ -969,6 +1050,7 @@ class PracticeRecorder { correctAnswerMap, scoreInfo, questionTypePerformance: results?.questionTypePerformance || {}, + ...annotations, metadata, suiteSessionId, createdAt: resolvedEndTime, @@ -979,7 +1061,8 @@ class PracticeRecorder { scoreInfo, interactions: results?.interactions || [], isRealData: true, - source: results?.source || 'practice_page' + source: results?.source || 'practice_page', + ...this.clonePlainObject(annotations) }) }; @@ -1006,7 +1089,7 @@ class PracticeRecorder { } try { - const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord; + const savedRecord = await this.savePracticeRecord(practiceRecord); if (!syntheticSession && this.activeSessions.has(resolvedExamId)) { this.endPracticeSession(resolvedExamId); @@ -1019,11 +1102,13 @@ class PracticeRecorder { return savedRecord; } catch (error) { console.error('[PracticeRecorder] 处理完成会话时出错:', error); - await this.saveToTemporaryStorage(practiceRecord); - if (!syntheticSession && this.activeSessions.has(resolvedExamId)) { - this.endPracticeSession(resolvedExamId, 'save_failed'); + try { + await this.saveToTemporaryStorage(practiceRecord); + } catch (recoveryError) { + console.error('[PracticeRecorder] canonical 与 recovery 提交均失败:', recoveryError); + error.recoveryError = recoveryError; } - return practiceRecord; + throw error; } } @@ -1133,6 +1218,7 @@ class PracticeRecorder { if (!this.activeSessions.has(examId)) return; let session = this.activeSessions.get(examId); + const sessionEntityId = this.activeSessionEntityId(session); // 如果会话未完成,创建中断记录 if (reason !== 'completed' && session.status !== 'completed') { @@ -1162,8 +1248,8 @@ class PracticeRecorder { // 清理会话 this.activeSessions.delete(examId); this.cleanupSessionListener(examId); - this.saveActiveSessions().catch(error => { - console.error('[PracticeRecorder] 保存活动会话失败:', error); + window.AppData.recovery.discardActiveSession(sessionEntityId).catch(error => { + console.error('[PracticeRecorder] 清理活动会话失败:', error); }); console.log(`Practice session ended: ${examId} (${reason})`); @@ -1240,59 +1326,49 @@ class PracticeRecorder { * 保存所有会话 */ async saveAllSessions() { - try { - await this.saveActiveSessions(); - console.log('Auto-saved all active sessions'); - } catch (error) { - console.error('[PracticeRecorder] 保存活动会话失败:', error); - } + await this.saveActiveSessions(); + console.log('Auto-saved all active sessions'); } /** * 保存活动会话到存储 */ async saveActiveSessions() { - const sessionsArray = Array.from(this.activeSessions.values()); - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') { - await practiceCoreStore.writeMeta('active_sessions', sessionsArray); - return; + for (const session of this.activeSessions.values()) { + await this.persistActiveSession(session); } - await this.metaRepo.set('active_sessions', sessionsArray); } /** * 保存练习记录 */ - async savePracticeRecord(record) { + async savePracticeRecord(record, options = {}) { const maxRetries = 3; const storageReadyRecord = this.prepareRecordForStorage(record); + const saveOperationId = storageReadyRecord.operationId || this.generateOperationId('practice-complete'); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`[PracticeRecorder] 开始保存练习记录(尝试 ${attempt}/${maxRetries}):`, record.id); - const practiceRecordApi = window.PracticeRecordAPI; - if (!practiceRecordApi || typeof practiceRecordApi.saveRecord !== 'function') { - throw new Error('PracticeRecordAPI not available'); - } - - const savedRawRecord = await practiceRecordApi.saveRecord(storageReadyRecord, { - updateStats: true + const receipt = await window.AppData.practice.completeAttempt({ + record: storageReadyRecord, + operationId: saveOperationId }); + const savedRawRecord = receipt.record; const savedRecord = this.restoreRecordAnswerState(savedRawRecord, record); - console.log(`[PracticeRecorder] PracticeRecordAPI 保存成功: ${savedRecord.id}`); + console.log(`[PracticeRecorder] AppData.practice 保存成功: ${savedRecord.id}`); const verified = await this.verifyRecordSaved(savedRecord.id); if (!verified) { - console.warn('[PracticeRecorder] PracticeRecordAPI 保存后未立即检出,稍后将由同步任务纠正'); + console.warn('[PracticeRecorder] AppData.practice 保存后未立即检出,稍后将由同步任务纠正'); } else { console.log('[PracticeRecorder] 记录保存验证成功'); } return savedRecord; } catch (error) { console.error( - `[PracticeRecorder] PracticeRecordAPI 保存失败 (尝试 ${attempt}):`, + `[PracticeRecorder] AppData.practice 保存失败 (尝试 ${attempt}):`, { error: error?.message, validationErrors: error?.validationErrors || null, @@ -1302,7 +1378,7 @@ class PracticeRecorder { ); if (attempt === maxRetries || this.isCriticalError(error)) { - return await this.retrySaveWithStandardizedRecord(record); + return await this.retrySaveWithStandardizedRecord(record, saveOperationId); } const delay = attempt * 100; @@ -1311,46 +1387,43 @@ class PracticeRecorder { } } - return await this.retrySaveWithStandardizedRecord(record); + return await this.retrySaveWithStandardizedRecord(record, saveOperationId); } /** * 用标准化后的 payload 再走统一 API 保存。 */ - async retrySaveWithStandardizedRecord(record) { + async retrySaveWithStandardizedRecord(record, operationId = null) { try { console.log('[PracticeRecorder] 使用标准化记录重试保存'); - const standardizedRecord = this.normalizeRecordForPracticeRecordApi(record); - const practiceRecordApi = window.PracticeRecordAPI; - if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') { - return await practiceRecordApi.saveRecord(standardizedRecord, { - updateStats: true - }); - } - - throw new Error('PracticeRecordAPI unavailable'); + const examIndex = await window.resolveActiveLibraryIndex(); + const standardizedRecord = this.normalizeRecordForAppData(record, examIndex); + const receipt = await window.AppData.practice.completeAttempt({ + record: standardizedRecord, + operationId: operationId || standardizedRecord.operationId || this.generateOperationId('practice-complete') + }); + return receipt.record; } catch (error) { console.error('[PracticeRecorder] 标准化重试保存失败:', { error: error?.message, validationErrors: error?.validationErrors || null, recordSummary: this.buildRecordLogSummary(record) }, error); - await this.saveToTemporaryStorage(record); - throw new Error(`All save methods failed: ${error.message}`); + throw error; } } /** * 标准化记录格式(用于统一 API 重试保存)。 */ - normalizeRecordForPracticeRecordApi(recordData) { + normalizeRecordForAppData(recordData, examIndex = []) { const now = new Date().toISOString(); const resolvedExamId = this.inferExamId(recordData); const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime()) ? new Date(recordData.endTime).toISOString() : now; - const examEntry = this.lookupExamIndexEntry(resolvedExamId); + const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex); const inferredType = this.normalizePracticeType( recordData.type || recordData.metadata?.type @@ -1410,6 +1483,8 @@ class PracticeRecorder { recordData.realData?.scoreInfo?.score, recordData.score ); + const annotations = this.resolveAnnotationState(recordData, [recordData.metadata || {}]); + metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions); return { // 基础信息 @@ -1439,11 +1514,13 @@ class PracticeRecorder { correctAnswerMap, scoreInfo: Object.assign({}, recordData.scoreInfo || {}, { details: answerDetails }), questionTypePerformance: recordData.questionTypePerformance || {}, + ...annotations, realData: Object.assign({}, recordData.realData || {}, { answers: answerMap, correctAnswers: correctAnswerMap, correctAnswerMap, - scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails }) + scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails }), + ...this.clonePlainObject(annotations) }), // 元数据 @@ -1461,17 +1538,7 @@ class PracticeRecorder { */ async verifyRecordSaved(recordId) { try { - const practiceRecordApi = window.PracticeRecordAPI; - if (practiceRecordApi && typeof practiceRecordApi.getById === 'function') { - const record = await practiceRecordApi.getById(recordId); - return !!record; - } - if (practiceRecordApi && typeof practiceRecordApi.list === 'function') { - const records = await practiceRecordApi.list(); - const list = Array.isArray(records) ? records : []; - return list.some(r => r && (r.id === recordId || r.sessionId === recordId)); - } - return false; + return Boolean(await window.AppData.practice.get(recordId, { projection: 'light' })); } catch (error) { console.error('[PracticeRecorder] 验证记录保存时出错', error); return false; @@ -1533,11 +1600,23 @@ class PracticeRecorder { this.convertComparisonToAnswerMap(record.answerComparison || record.realData?.answerComparison, 'userAnswer') ); const correctMap = this.resolveRecordCorrectAnswerMap(record); + const annotations = this.resolveAnnotationState(record, [record.metadata || {}]); const answerList = this.convertAnswerMapToArray(answerMap, correctMap); clone.answerList = answerList; - clone.answers = answerList; + // AppData v2 stores canonical answer maps in the detail entity. Converting + // `answers` to the legacy array shape here makes persisted review records + // unreadable to consumers that intentionally accept maps only. + clone.answers = answerMap; clone.correctAnswerMap = correctMap; + clone.questionTypeMap = this.clonePlainObject( + record.questionTypeMap || record.realData?.questionTypeMap || {} + ); + clone.interactions = this.clonePlainObject( + Array.isArray(record.interactions) + ? record.interactions + : (Array.isArray(record.realData?.interactions) ? record.realData.interactions : []) + ); clone.answerDetails = this.buildCanonicalAnswerDetails( answerMap, correctMap, @@ -1547,6 +1626,10 @@ class PracticeRecorder { record.answerComparison || record.realData?.answerComparison ); clone.scoreInfo = Object.assign({}, clone.scoreInfo || {}, { details: clone.answerDetails }); + Object.assign(clone, this.clonePlainObject(annotations)); + clone.metadata = Object.assign({}, clone.metadata || {}, { + markedQuestions: this.clonePlainObject(annotations.markedQuestions) + }); if (clone.answerComparison) { clone.answerComparison = this.normalizeAnswerComparison(clone.answerComparison); @@ -1556,7 +1639,8 @@ class PracticeRecorder { answers: answerMap, correctAnswers: correctMap, correctAnswerMap: correctMap, - scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails }) + scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails }), + ...this.clonePlainObject(annotations) }); if (clone.realData.answerComparison) { clone.realData.answerComparison = this.normalizeAnswerComparison(clone.realData.answerComparison); @@ -1603,6 +1687,9 @@ class PracticeRecorder { correctAnswerMap: clone.correctAnswerMap, scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details }) }); + const annotations = this.resolveAnnotationState(clone, [sourceRecord || {}]); + Object.assign(clone, this.clonePlainObject(annotations)); + clone.realData = Object.assign({}, clone.realData, this.clonePlainObject(annotations)); return clone; } @@ -1623,42 +1710,43 @@ class PracticeRecorder { * 保存到临时存储 */ async saveToTemporaryStorage(record) { - try { - const existing = await this.metaRepo.get('temp_practice_records', []); - const tempRecords = Array.isArray(existing) ? [...existing] : []; - tempRecords.push({ - ...record, - tempSavedAt: new Date().toISOString(), - needsRecovery: true - }); - - // 限制临时记录数量 - const finalTempRecords = tempRecords.length > 50 ? tempRecords.slice(-50) : tempRecords; + const recordId = String(record && (record.id || record.sessionId) || `record-${Date.now()}`); + const receipt = await window.AppData.recovery.saveDraft({ + id: `practice-record:${recordId}`, + recordId, + kind: 'practice_record_recovery', + record: this.clonePlainObject(record), + tempSavedAt: new Date().toISOString(), + needsRecovery: true + }); - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') { - await practiceCoreStore.writeMeta('temp_practice_records', finalTempRecords); - } else { - await this.metaRepo.set('temp_practice_records', finalTempRecords); + try { + const drafts = await window.AppData.recovery.listDrafts(); + const recoveryDrafts = (Array.isArray(drafts) ? drafts : []) + .filter((draft) => draft && draft.kind === 'practice_record_recovery') + .sort((left, right) => Date.parse(left.updatedAt || left.tempSavedAt || 0) - Date.parse(right.updatedAt || right.tempSavedAt || 0)); + for (const stale of recoveryDrafts.slice(0, Math.max(0, recoveryDrafts.length - 50))) { + await window.AppData.recovery.discardDraft(stale.id); } - console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id); - } catch (error) { - console.error('[PracticeRecorder] 临时存储也失败', error); + console.warn('[PracticeRecorder] recovery 草稿清理失败,不影响已提交草稿:', error); } + console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id); + return receipt; } /** * 保存中断记录 */ async saveInterruptedRecord(record) { - const existing = await this.metaRepo.get('interrupted_records', []); - const records = Array.isArray(existing) ? [...existing] : []; - records.push(record); - - const finalRecords = records.length > 100 ? records.slice(-100) : records; - - await this.metaRepo.set('interrupted_records', finalRecords); + await window.AppData.recovery.saveInterrupted(record); + const existing = await window.AppData.recovery.listInterrupted(); + const records = (Array.isArray(existing) ? existing : []) + .slice() + .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0)); + for (const stale of records.slice(100)) { + await window.AppData.recovery.discardInterrupted(stale.id || stale.sessionId || stale.recordId); + } console.log(`Interrupted record saved: ${record.id}`); } @@ -1666,33 +1754,12 @@ class PracticeRecorder { * 更新用户统计 */ async updateUserStats(practiceRecord) { - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.recalculateStats !== 'function') { - throw new Error('PracticeRecordAPI.recalculateStats unavailable'); - } - await window.PracticeRecordAPI.recalculateStats(); - console.log('User stats recalculated through PracticeRecordAPI'); + await window.AppData.practice.getStats(); } async listPracticeRecordsForStats() { - // 统计读取只需元数据字段,使用轻量 listSummary 避免反序列化+克隆完整记录 - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') { - try { - const records = await window.PracticeRecordAPI.listSummary(); - return Array.isArray(records) ? records : []; - } catch (error) { - console.warn('[PracticeRecorder] PracticeRecordAPI.listSummary 统计读取失败:', error); - } - } - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - try { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } catch (error) { - console.warn('[PracticeRecorder] PracticeRecordAPI.list 统计读取失败:', error); - } - } - - return []; + const records = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(records) ? records : []; } /** @@ -1707,11 +1774,10 @@ class PracticeRecorder { */ async getPracticeRecords(filters = {}) { try { - const practiceRecordApi = window.PracticeRecordAPI; - if (!practiceRecordApi || typeof practiceRecordApi.list !== 'function') { - return []; - } - const records = await practiceRecordApi.list(); + // 过滤条件(examId/metadata.category/startTime/date/accuracy)与唯一内部消费者 + // getDataIntegrityReport -> validateRecordIntegrity(id/examId/startTime/endTime/accuracy/duration) + // 都在 light 投影覆盖范围内,不需要拉取答题详情。 + const records = await window.AppData.practice.list({ projection: 'light' }); const list = Array.isArray(records) ? records : []; if (Object.keys(filters).length === 0) { return list; @@ -1727,15 +1793,12 @@ class PracticeRecorder { return true; }); } catch (error) { - console.error('Failed to get practice records from PracticeRecordAPI:', error); + console.error('Failed to get practice records from AppData.practice:', error); return []; } } getDefaultUserStats() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getDefaultStats === 'function') { - return window.PracticeRecordAPI.getDefaultStats(); - } return { totalPractices: 0, totalTimeSpent: 0, @@ -1749,13 +1812,6 @@ class PracticeRecorder { }; } - getUnifiedBackupManager() { - if (window.DataBackupManager) { - return new window.DataBackupManager(); - } - throw new Error('DataBackupManager unavailable'); - } - convertRecordsToCSV(records) { const list = Array.isArray(records) ? records : []; if (list.length === 0) return ''; @@ -1791,10 +1847,7 @@ class PracticeRecorder { * 获取用户统计 */ async getUserStats() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - return await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() }); - } - return this.getDefaultUserStats(); + return Object.assign(this.getDefaultUserStats(), await window.AppData.practice.getStats()); } /** @@ -1802,47 +1855,53 @@ class PracticeRecorder { */ async exportData(format = 'json') { const normalizedFormat = String(format || 'json').toLowerCase(); - // CSV 导出只需元数据字段,使用轻量 listSummary 避免加载完整记录 - const records = normalizedFormat === 'csv' - ? await this.listPracticeRecordsForStats() - : await this.getPracticeRecords(); if (normalizedFormat === 'csv') { + const records = await this.listPracticeRecordsForStats(); return this.convertRecordsToCSV(records); } if (normalizedFormat !== 'json') { throw new Error(`Unsupported export format: ${format}`); } - return JSON.stringify({ - exportDate: new Date().toISOString(), - version: PRACTICE_RECORDER_EXPORT_VERSION, - practiceRecords: records, - userStats: await this.getUserStats() - }, null, 2); + const snapshot = await window.AppData.backups.export({ domains: ['practice'] }); + return JSON.stringify(snapshot, null, 2); } /** * 导入练习数据 */ - importData(data, options = {}) { - const manager = this.getUnifiedBackupManager(); + async importData(data, options = {}) { const mergeMode = options.merge === false || options.mergeMode === 'replace' ? 'replace' : (options.mergeMode || 'merge'); - return manager.importPracticeData(data, Object.assign({}, options, { mergeMode })); + const backup = options.createBackup === false + ? null + : await window.AppData.backups.create({ type: 'pre-import' }); + const payload = Array.isArray(data) ? { records: data } : data; + const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mergeMode }); + const receipt = await window.AppData.backups.commitImport(preview.id, { + operationId: options.operationId, + confirmDestructive: mergeMode === 'replace' + }); + try { + await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup && backup.id, practice: preview.practice }); + } catch (historyError) { + console.warn('[PracticeRecorder] 导入已提交,但历史记录写入失败:', historyError); + } + return Object.assign({}, receipt, { backupId: backup && backup.id }); } /** * 创建数据备份 */ createBackup(backupName = null) { - return this.getUnifiedBackupManager().createBackup(backupName, 'practice_recorder'); + return window.AppData.backups.create({ id: backupName || undefined, type: 'practice-recorder' }); } /** * 恢复数据备份 */ restoreBackup(backupId) { - return this.getUnifiedBackupManager().restoreBackup(backupId); + return window.AppData.backups.restore(backupId); } /** @@ -1850,10 +1909,7 @@ class PracticeRecorder { */ getBackups() { try { - if (window.BackupAPI && typeof window.BackupAPI.list === 'function') { - return window.BackupAPI.list(); - } - return this.scoreStorage.getBackups(); + return window.AppData.backups.list(); } catch (error) { console.error('Failed to get backups:', error); return []; @@ -1865,7 +1921,7 @@ class PracticeRecorder { */ getStorageStats() { try { - return this.scoreStorage.getStorageStats(); + return window.AppData.status(); } catch (error) { console.error('Failed to get storage stats:', error); return null; @@ -1873,17 +1929,32 @@ class PracticeRecorder { } generateRecordId() { - if (this.scoreStorage && typeof this.scoreStorage.generateRecordId === 'function') { - return this.scoreStorage.generateRecordId(); - } return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } + generateOperationId(prefix = 'operation') { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return `${prefix}_${window.crypto.randomUUID()}`; + } + } catch (_) { + // fall through to timestamp entropy + } + return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 12)}`; + } + /** - * 生成会话ID + * 生成会话ID(可选带 examId 前缀,便于与宿主 expectedSessionId 对齐) */ - generateSessionId() { - return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + generateSessionId(examId) { + const suffix = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const normalizedExamId = typeof examId === 'string' + ? examId.trim().replace(/\s+/g, '-') + : (examId != null ? String(examId).trim().replace(/\s+/g, '-') : ''); + if (normalizedExamId) { + return `${normalizedExamId}_${suffix}`; + } + return `session_${suffix}`; } extractExamIdFromRecordId(recordId) { @@ -1933,8 +2004,8 @@ class PracticeRecorder { } // 获取题目信息 - const examIndex = await this.metaRepo.get('exam_index', []); - const examList = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []); + const examIndex = await window.resolveActiveLibraryIndex(); + const examList = Array.isArray(examIndex) ? examIndex : []; const exam = examList.find(e => e.id === examId); if (!exam) { @@ -1945,12 +2016,11 @@ class PracticeRecorder { // 构造增强的练习记录 const practiceRecord = this.createRealPracticeRecord(exam, validatedData); - // 保存记录 - 这里ScoreStorage会自动更新用户统计 - const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord; + // AppData 在权威提交后调度统计投影。 + const savedRecord = await this.savePracticeRecord(practiceRecord); // 清理活动会话 - this.activeSessions.delete(examId); - await this.saveActiveSessions(); + this.endPracticeSession(examId); // 触发完成事件 this.dispatchSessionEvent('realDataProcessed', { @@ -2055,13 +2125,10 @@ class PracticeRecorder { ); const totalQuestions = scoreInfo.total || Object.keys(correctAnswerMap).length || Object.keys(answerMap).length; const accuracy = scoreInfo.accuracy || (totalQuestions > 0 ? score / totalQuestions : 0); - const highlights = Array.isArray(realData.highlights) ? realData.highlights.slice() : []; - const markedQuestions = Array.isArray(realData.markedQuestions) ? realData.markedQuestions.slice() : []; - const scrollY = Number.isFinite(Number(realData.scrollY)) ? Number(realData.scrollY) : 0; - const noteText = typeof realData.noteText === 'string' ? realData.noteText : ''; + const annotations = this.resolveAnnotationState(realData); const practiceRecord = { - // 基础信息 - 与ScoreStorage兼容 + // 基础信息 id: recordId, examId: exam.id, sessionId: realData.sessionId, @@ -2079,30 +2146,40 @@ class PracticeRecorder { correctAnswers: score, // 正确答案数等于分数 accuracy: accuracy, - // 答题详情 - 转换为ScoreStorage期望的格式 + // 答题详情 answers: answerList, correctAnswerMap, answerComparison, questionTypeMap, questionTypePerformance: this.extractQuestionTypePerformance(realData), - highlights, - scrollY, - markedQuestions, - noteText, + ...annotations, - // 元数据 - 与ScoreStorage兼容 + // 元数据 metadata: { examTitle: exam.title || '', category: exam.category || '', frequency: exam.frequency || '', - markedQuestions: markedQuestions.slice(), + markedQuestions: this.clonePlainObject(annotations.markedQuestions), collectionMethod: 'automatic', dataQuality: this.assessDataQuality(realData), - processingTime: Date.now() + processingTime: Date.now(), + // 启动时捕获的题库配置 ID:优先取 realData 与其 metadata 显式透传的值; + // 若上游未透传则显式写入 null(保留 key),让 AppData 记录 provenance + // 不再回退读取当前激活题库,避免记录来源在提交时被切换题库影响。 + libraryConfigurationId: (realData + && realData.libraryConfigurationId !== undefined + && realData.libraryConfigurationId !== null) + ? realData.libraryConfigurationId + : (realData + && realData.metadata + && realData.metadata.libraryConfigurationId !== undefined + && realData.metadata.libraryConfigurationId !== null) + ? realData.metadata.libraryConfigurationId + : null }, // 额外的真实数据信息 - realData: { + realData: Object.assign({}, realData, { sessionId: realData.sessionId, answers: answerMap, correctAnswers: correctAnswerMap, @@ -2111,15 +2188,12 @@ class PracticeRecorder { questionTypeMap, answerHistory: realData.answerHistory || {}, interactions: realData.interactions || [], - highlights, - scrollY, - markedQuestions, - noteText, + ...this.clonePlainObject(annotations), scoreInfo: scoreInfo, pageType: realData.pageType, url: realData.url, source: scoreInfo.source || 'data_collector' - }, + }), // 系统信息 dataSource: 'real', @@ -2131,7 +2205,7 @@ class PracticeRecorder { } /** - * 转换答案格式为ScoreStorage兼容格式 + * 转换答案格式为 canonical record 格式 */ convertAnswersFormat(answers, correctAnswerMap = {}, answerComparison = {}, questionTypeMap = {}) { if (!answers || typeof answers !== 'object') { @@ -2326,7 +2400,7 @@ class PracticeRecorder { sessionId: sessionId, timestamp: Date.now() } - }, '*'); + }, window.location.protocol === 'file:' ? '*' : window.location.origin); } } @@ -2335,8 +2409,11 @@ class PracticeRecorder { */ async recoverTemporaryRecords() { try { - const tempRecords = await this.metaRepo.get('temp_practice_records', []); - const list = Array.isArray(tempRecords) ? tempRecords : []; + const tempRecords = await window.AppData.recovery.listDrafts(); + const list = (Array.isArray(tempRecords) ? tempRecords : []).filter((draft) => ( + draft + && (draft.kind === 'practice_record_recovery' || draft.needsRecovery === true) + )); if (list.length === 0) { console.log('[PracticeRecorder] 没有需要恢复的临时记录'); @@ -2346,12 +2423,12 @@ class PracticeRecorder { console.log(`[PracticeRecorder] 发现 ${list.length} 条临时记录,开始恢复`); let recoveredCount = 0; - const failedRecords = []; - for (const tempRecord of list) { try { - // 移除临时标识 - const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord; + const sourceRecord = tempRecord.record && typeof tempRecord.record === 'object' + ? tempRecord.record + : tempRecord; + const { tempSavedAt, needsRecovery, kind, ...cleanRecord } = sourceRecord; const sanitized = this.sanitizeRecoveredRecord(cleanRecord); if (!sanitized) { console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord?.id); @@ -2360,34 +2437,16 @@ class PracticeRecorder { // 尝试正常保存 await this.savePracticeRecord(sanitized); + await window.AppData.recovery.discardDraft(tempRecord.id); recoveredCount++; console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized.id}`); } catch (error) { console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord.id}`, error); - failedRecords.push(tempRecord); } } - - // 清理已恢复的临时记录 - if (failedRecords.length === 0) { - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.removeMeta === 'function') { - await practiceCoreStore.removeMeta('temp_practice_records'); - } else { - await this.metaRepo.remove('temp_practice_records'); - } - console.log(`[PracticeRecorder] 所有${recoveredCount} 条临时记录恢复成功`); - } else { - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') { - await practiceCoreStore.writeMeta('temp_practice_records', failedRecords); - } else { - await this.metaRepo.set('temp_practice_records', failedRecords); - } - console.log(`[PracticeRecorder] 恢复了${recoveredCount} 条记录,${failedRecords.length} 条失败`); - } + console.log(`[PracticeRecorder] 已恢复 ${recoveredCount} 条临时记录`); } catch (error) { console.error('[PracticeRecorder] 恢复临时记录时出错', error); @@ -2460,7 +2519,7 @@ class PracticeRecorder { }); // 检查临时记录 - const tempRecords = await this.metaRepo.get('temp_practice_records', []); + const tempRecords = await window.AppData.recovery.listDrafts(); const tempList = Array.isArray(tempRecords) ? tempRecords : []; report.temporaryRecords.total = tempList.length; report.temporaryRecords.needsRecovery = tempList.filter(r => r && r.needsRecovery).length; @@ -2480,9 +2539,7 @@ class PracticeRecorder { // 检查存储状态 try { - const storageInfo = window.storage && typeof window.storage.getStorageInfo === 'function' - ? await window.storage.getStorageInfo() - : null; + const storageInfo = window.AppData.status(); report.storage.quota = storageInfo; } catch (error) { report.storage.available = false; @@ -2554,3 +2611,9 @@ class PracticeRecorder { // 确保全局可用 window.PracticeRecorder = PracticeRecorder; +// The practice bundle is loaded on demand and may arrive after the bootstrap +// fallback's bounded polling window. Upgrade immediately when the real class +// becomes available so suite submissions never remain on the light recorder. +if (window.app && typeof window.app.instantiatePracticeRecorder === 'function') { + window.app.instantiatePracticeRecorder(); +} diff --git a/js/practice-page-enhancer.js b/js/practice-page-enhancer.js index 72433ef2..28ee2a5d 100644 --- a/js/practice-page-enhancer.js +++ b/js/practice-page-enhancer.js @@ -14,6 +14,20 @@ } console.log('[PracticeEnhancer] 初始化增强器'); + const HOST_MESSAGE_SOURCE = 'exam_host'; + + function deriveParentOriginFromReferrer() { + try { + if (!document.referrer) return ''; + const parsed = new URL(document.referrer, window.location.href); + // Chromium: file URL.origin is "file://", postMessage event.origin is "null". + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (_) { + return ''; + } + } const DEFAULT_ENHANCER_CONFIG = { autoInitialize: true, @@ -766,6 +780,10 @@ sessionId: null, examId: null, // 新增:存储唯一的examId parentWindow: null, + expectedParentOrigin: deriveParentOriginFromReferrer(), + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', answers: {}, correctAnswers: {}, interactions: [], @@ -882,9 +900,7 @@ } this.enhancerBaseUrl = this.getEnhancerBaseUrl(); - await this.ensureStorageAvailable(); await this.ensureSpellingErrorCollector(); - await this.prepareStorageNamespace(); // 检测多套题结构 this.isMultiSuite = this.detectMultiSuiteStructure(); @@ -1081,95 +1097,6 @@ }).filter(Boolean); }, - ensureStorageAvailable: async function () { - try { - if (window.storage && typeof window.storage.setNamespace === 'function') { - if (window.storage.ready && typeof window.storage.ready.then === 'function') { - await window.storage.ready; - } - return true; - } - - const tryLoad = async (urls) => { - for (const url of urls) { - if (!url) continue; - try { - console.log('[PracticeEnhancer] 尝试加载存储管理器:', url); - await dependencyLoader.loadScript(url); - if (window.storage && typeof window.storage.setNamespace === 'function') { - if (window.storage.ready && typeof window.storage.ready.then === 'function') { - await window.storage.ready; - } - return true; - } - } catch (error) { - console.warn('[PracticeEnhancer] 存储管理器加载失败:', error); - } - } - return false; - }; - - const baseUrl = this.getEnhancerBaseUrl(); - const baseCandidate = new URL('utils/storage.js', baseUrl).href; - const fallbackUrls = this.buildFallbackUrls([ - '../../../../js/utils/storage.js', - '../../../js/utils/storage.js', - '../../js/utils/storage.js', - '../js/utils/storage.js', - './js/utils/storage.js' - ]); - - const loaded = await tryLoad([baseCandidate, ...fallbackUrls]); - if (loaded) return true; - } catch (error) { - console.warn('[PracticeEnhancer] 加载存储管理器失败:', error); - } - - // 创建简易回退存储,确保流程不中断 - console.warn('[PracticeEnhancer] 使用简易回退存储'); - const fallbackPrefix = 'exam_system_'; - const safeStore = (() => { - try { - return window.localStorage; - } catch (_) { - return null; - } - })(); - - const stubStorage = { - namespace: '', - ready: Promise.resolve(), - setNamespace(ns) { this.namespace = ns ? `${ns}_` : ''; }, - async set(key, value) { - if (!safeStore) return false; - const k = fallbackPrefix + this.namespace + key; - safeStore.setItem(k, JSON.stringify({ value })); - return true; - }, - async get(key) { - if (!safeStore) return null; - const k = fallbackPrefix + this.namespace + key; - const raw = safeStore.getItem(k); - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - return parsed && parsed.value !== undefined ? parsed.value : parsed; - } catch (_) { - return null; - } - }, - async remove(key) { - if (!safeStore) return false; - const k = fallbackPrefix + this.namespace + key; - safeStore.removeItem(k); - return true; - } - }; - - window.storage = stubStorage; - return true; - }, - ensureSpellingErrorCollector: async function () { if (window.spellingErrorCollector) { return true; @@ -1211,42 +1138,6 @@ return loaded; }, - prepareStorageNamespace: async function () { - // 设置共享命名空间 - try { - if (window.storage?.ready) { - await window.storage.ready; - } - - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[PracticeEnhancer] 已设置共享命名空间: exam_system'); - - // 验证命名空间设置是否生效 - setTimeout(async () => { - const testKey = 'namespace_test_enhancer'; - const testValue = 'test_value_enhancer_' + Date.now(); - try { - await window.storage.set(testKey, testValue); - const retrievedValue = await window.storage.get(testKey); - if (retrievedValue === testValue) { - console.log('✅ 增强器命名空间设置验证成功: 存储和读取正常'); - } else { - console.warn('❌ 增强器命名空间设置验证失败: 读取值不匹配'); - } - await window.storage.remove(testKey); - } catch (error) { - console.error('❌ 增强器命名空间设置验证失败', error); - } - }, 1000); - } else { - console.warn('[PracticeEnhancer] 存储管理器未加载或setNamespace方法不可用'); - } - } catch (error) { - console.error('[PracticeEnhancer] 存储初始化失败,跳过命名空间设置', error); - } - }, - cleanup: function () { console.log('[PracticeEnhancer] 清理资源'); if (this.answerCollectionInterval) { @@ -1857,9 +1748,49 @@ } const messageType = String(payload.type).toUpperCase(); const payloadData = payload.data || {}; + if (!event || event.source !== this.parentWindow || payload.source !== HOST_MESSAGE_SOURCE) { + return; + } if (messageType === 'INIT_SESSION' || messageType === 'INIT_EXAM_SESSION') { const initData = payloadData; + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + const declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : ''; + const incomingToken = typeof initData.windowSessionToken === 'string' + ? initData.windowSessionToken.trim() + : ''; + if (!incomingToken) return; + const expectedParentOrigin = this.expectedParentOrigin + && this.expectedParentOrigin !== 'file://' + && !String(this.expectedParentOrigin).startsWith('file:') + ? this.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) { + return; + } + this.parentOrigin = expectedParentOrigin; + this.parentOriginIsOpaque = false; + } else if (window.location.protocol === 'file:') { + const trustedFileOrigin = incomingOrigin === 'null' + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) { + return; + } + this.parentOrigin = 'null'; + this.parentOriginIsOpaque = true; + } else { + const trustedWebOrigin = Boolean(incomingOrigin) + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) { + return; + } + this.parentOrigin = incomingOrigin; + this.parentOriginIsOpaque = false; + } + this.windowSessionToken = incomingToken; this.sessionId = initData.sessionId; this.examId = initData.examId; // 存储 examId if (initData.reviewSessionId) { @@ -1892,6 +1823,17 @@ return; } + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + const incomingToken = typeof payloadData.windowSessionToken === 'string' + ? payloadData.windowSessionToken.trim() + : ''; + const originMatches = this.parentOriginIsOpaque + ? incomingOrigin === 'null' + : Boolean(this.parentOrigin && incomingOrigin === this.parentOrigin); + if (!originMatches || !this.windowSessionToken || incomingToken !== this.windowSessionToken) { + return; + } + if (messageType === 'REPLAY_PRACTICE_RECORD') { this.applyReplayRecord(payloadData || {}); return; @@ -3380,6 +3322,7 @@ // Requirement 9.1: 必须包含的基本字段 examId: `${this.examId}_${suiteId}`, // Requirement 9.2: examId包含套题标识 sessionId: this.sessionId, + suiteSessionId: this.suiteSessionId || null, answers: suiteAnswers, // Requirement 9.3: 答案键使用"套题ID::问题ID"格式 correctAnswers: suiteCorrectAnswers, @@ -4530,29 +4473,57 @@ return null; }, + createSubmissionId: function () { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return `practice-submit-${window.crypto.randomUUID()}`; + } + } catch (_) { + // Fall through to the session-bound fallback. + } + return `practice-submit-${this.sessionId || this.examId || 'session'}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + }, + sendMessage: function (type, data) { if (!this.parentWindow) { console.warn('[PracticeEnhancer] 无父窗口,无法发送消息'); - return; + return false; } if (this.readOnly && type === 'PRACTICE_COMPLETE') { console.info('[PracticeEnhancer] 回顾模式阻止 PRACTICE_COMPLETE 上报'); - return; + return false; } - this.runHooks('beforeSendMessage', type, data); + const payload = data && typeof data === 'object' ? data : {}; + if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') { + payload.sessionId = payload.sessionId || this.sessionId || null; + payload.submissionId = payload.submissionId || this.createSubmissionId(); + } + this.runHooks('beforeSendMessage', type, payload); + const secureData = Object.assign({}, payload, { + windowSessionToken: this.windowSessionToken || null + }); const message = { type: type, - data: data, + data: secureData, source: 'practice_page', timestamp: Date.now() }; try { - this.parentWindow.postMessage(message, '*'); + const targetOrigin = this.parentOrigin && this.parentOrigin !== 'null' + ? this.parentOrigin + : (this.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) { + console.warn('[PracticeEnhancer] 缺少可信父窗口 origin,消息未发送:', type); + return false; + } + this.parentWindow.postMessage(message, targetOrigin); console.log('[PracticeEnhancer] 消息已发送:', type); + return true; } catch (error) { console.error('[PracticeEnhancer] 发送消息失败:', error); + return false; } }, diff --git a/js/utils/practiceTimerPreferences.js b/js/utils/practiceTimerPreferences.js index b5f40b0e..3a726fbd 100644 --- a/js/utils/practiceTimerPreferences.js +++ b/js/utils/practiceTimerPreferences.js @@ -1,8 +1,6 @@ (function initPracticeTimerPreferences(global) { 'use strict'; - var READING_KEY = 'ielts_reading_timer_preferences_v2'; - var LISTENING_KEY = 'ielts_listening_timer_preferences_v1'; var VERSION = 1; var DEFAULTS = { version: VERSION, @@ -39,26 +37,38 @@ }; } - function keyFor(scope) { - return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY; + var cache = Object.create(null); + var hydrationPromise = null; + function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; } + function hydrateTimerPreferences() { + if (cache.reading && cache.listening) return Promise.resolve(true); + if (hydrationPromise) return hydrationPromise; + if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false); + hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getTimer(); + cache.reading = normalize(stored && stored.reading); + cache.listening = normalize(stored && stored.listening); + return true; + }).catch(function onTimerPreferenceLoadError(error) { + hydrationPromise = null; + console.warn('[PracticeTimerPreferences] 加载失败:', error); + return false; + }); + return hydrationPromise; } function read(scope) { - try { - var raw = global.localStorage && global.localStorage.getItem(keyFor(scope)); - return normalize(raw ? JSON.parse(raw) : null); - } catch (_) { - return normalize(null); - } + return normalize(cache[normalizeScope(scope)]); } - function save(scope, preferences) { + async function save(scope, preferences) { + await hydrateTimerPreferences(); + if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable'); + var normalizedScope = normalizeScope(scope); var next = normalize(preferences); - try { - if (global.localStorage) { - global.localStorage.setItem(keyFor(scope), JSON.stringify(next)); - } - } catch (_) { } + await global.AppData.preferences.setTimer(normalizedScope, next); + cache[normalizedScope] = next; return next; } @@ -66,15 +76,14 @@ return clampMinutes(value, DEFAULTS.countdownMinutes) * 60; } - global.PracticeTimerPreferences = { + var api = { VERSION: VERSION, - READING_KEY: READING_KEY, - LISTENING_KEY: LISTENING_KEY, DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)), normalize: normalize, read: read, save: save, - keyFor: keyFor, minutesToSeconds: minutesToSeconds }; + Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences }); + global.PracticeTimerPreferences = api; })(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/utils/suitePreference.js b/js/utils/suitePreference.js index 1240afbb..188af731 100644 --- a/js/utils/suitePreference.js +++ b/js/utils/suitePreference.js @@ -1,10 +1,6 @@ (function initSuitePreferenceUtils(global) { 'use strict'; - const FLOW_MODE_STORAGE_KEY = 'suite_flow_mode'; - const FREQUENCY_SCOPE_STORAGE_KEY = 'suite_frequency_scope'; - const AUTO_ADVANCE_STORAGE_KEY = 'suite_auto_advance_after_submit'; - const FLOW_MODES = ['classic', 'simulation', 'stationary']; const FREQUENCY_SCOPES = ['high', 'high_medium', 'all', 'custom']; @@ -119,50 +115,54 @@ return null; } - function readStorageValue(key) { - try { - if (global.localStorage && typeof global.localStorage.getItem === 'function') { - return global.localStorage.getItem(key); - } - } catch (_) { - // ignore read failures - } - return null; - } - - function writeStorageValue(key, value) { - try { - if (global.localStorage && typeof global.localStorage.setItem === 'function') { - global.localStorage.setItem(key, String(value)); - } - } catch (_) { - // ignore write failures - } + let hydrationPromise = null; + function hydrateSuitePreference() { + if (hydrationPromise) return hydrationPromise; + // runtime-entry.bundle.js is intentionally loaded before the data + // foundation. Do not memoize that early miss: a cached `false` would + // make every later resolver skip the persisted AppData preference. + if (!global.AppData || !global.AppData.preferences) { + return Promise.resolve(false); + } + hydrationPromise = Promise.resolve().then(async () => { + await global.AppData.ready; + const stored = await global.AppData.preferences.getSuite(); + if (stored && typeof stored === 'object') Object.assign(ensurePracticeConfig().suite, stored); + return true; + }).catch((error) => { + console.warn('[SuitePreference] 加载失败:', error); + return false; + }); + // A transient AppData initialization failure should be retryable on the + // next read, just like the pre-foundation early miss above. + hydrationPromise = hydrationPromise.then((hydrated) => { + if (!hydrated) hydrationPromise = null; + return hydrated; + }); + return hydrationPromise; } - function resolveSuitePreference(overrides = {}) { + async function resolveSuitePreference(overrides = {}) { + await hydrateSuitePreference(); const config = ensurePracticeConfig(); const suiteConfig = config.suite || {}; const flowMode = normalizeFlowMode(overrides.flowMode) || normalizeFlowMode(suiteConfig.flowMode) - || normalizeFlowMode(readStorageValue(FLOW_MODE_STORAGE_KEY)) || 'classic'; const frequencyScope = normalizeFrequencyScope(overrides.frequencyScope) || normalizeFrequencyScope(suiteConfig.frequencyScope) - || normalizeFrequencyScope(readStorageValue(FREQUENCY_SCOPE_STORAGE_KEY)) || 'all'; const overrideAutoAdvance = parseBoolean(overrides.autoAdvanceAfterSubmit); const configAutoAdvance = parseBoolean(suiteConfig.autoAdvanceAfterSubmit); - const storedAutoAdvance = parseBoolean(readStorageValue(AUTO_ADVANCE_STORAGE_KEY)); const fallbackAutoAdvance = flowMode !== 'stationary'; const autoAdvanceAfterSubmit = overrideAutoAdvance != null ? overrideAutoAdvance : (configAutoAdvance != null ? configAutoAdvance - : (storedAutoAdvance != null ? storedAutoAdvance : fallbackAutoAdvance)); + : fallbackAutoAdvance); config.suite.flowMode = flowMode; config.suite.frequencyScope = frequencyScope; @@ -176,24 +176,34 @@ } function persistSuitePreference(partial = {}) { - const current = resolveSuitePreference(); + const config = ensurePracticeConfig(); + const suiteConfig = config.suite || {}; + const fallbackCurrent = { + flowMode: normalizeFlowMode(suiteConfig.flowMode) || 'classic', + frequencyScope: normalizeFrequencyScope(suiteConfig.frequencyScope) || 'all', + autoAdvanceAfterSubmit: parseBoolean(suiteConfig.autoAdvanceAfterSubmit) + }; - const flowMode = normalizeFlowMode(partial.flowMode) || current.flowMode; - const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || current.frequencyScope; + const flowMode = normalizeFlowMode(partial.flowMode) || fallbackCurrent.flowMode; + const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || fallbackCurrent.frequencyScope; const partialAutoAdvance = parseBoolean(partial.autoAdvanceAfterSubmit); const autoAdvanceAfterSubmit = partialAutoAdvance != null ? partialAutoAdvance : (flowMode === 'stationary' ? false : true); - const config = ensurePracticeConfig(); config.suite.flowMode = flowMode; config.suite.frequencyScope = frequencyScope; config.suite.autoAdvanceAfterSubmit = autoAdvanceAfterSubmit; - writeStorageValue(FLOW_MODE_STORAGE_KEY, flowMode); - writeStorageValue(FREQUENCY_SCOPE_STORAGE_KEY, frequencyScope); - writeStorageValue(AUTO_ADVANCE_STORAGE_KEY, autoAdvanceAfterSubmit ? 'true' : 'false'); + hydrateSuitePreference().then((hydrated) => { + if (!hydrated || !global.AppData || !global.AppData.preferences) return; + return global.AppData.preferences.patchSuite({ + flowMode, + frequencyScope, + autoAdvanceAfterSubmit + }); + }).catch((error) => console.warn('[SuitePreference] 保存失败:', error)); return { flowMode, @@ -210,12 +220,19 @@ normalizeFrequencyScope, normalizeFrequency, isFrequencyIncluded, + ready: hydrateSuitePreference, resolveSuitePreference, persistSuitePreference }; global.SuitePreferenceUtils = api; + // Kick hydration off eagerly so any later resolver (including the + // synchronous readers inside suitePracticeMixin) does not race the very + // first AppData.preferences.getSuite() lookup. If the data foundation is + // not installed yet, hydrateSuitePreference deliberately retries later. + hydrateSuitePreference(); + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } From 1473cced47480cc45332356f023e0d08ad85a254 Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:34:11 +0800 Subject: [PATCH 10/18] feat(runtime): secure reading and listening practice flows --- js/app.js | 336 ++-- js/app/examActions.js | 109 +- js/app/main-entry.js | 116 +- js/app/spellingErrorCollector.js | 113 +- js/boot-fallbacks.js | 294 +--- js/components/SystemDiagnostics.js | 27 +- js/listeningRecordBridge.js | 179 ++- js/listeningUnifiedWrapper.js | 103 +- js/main.js | 1048 ++++++------- js/presentation/app-actions.js | 180 ++- js/runtime/lazyLoader.js | 15 +- js/runtime/readingHighlightShared.js | 7 + js/runtime/reviewHighlightDictionary.js | 144 +- js/runtime/unifiedReadingPage.js | 1862 +++++++++++++++++++++-- js/services/achievementManager.js | 622 +++----- js/utils/answerComparisonUtils.js | 178 +-- js/utils/answerMatchCore.js | 15 +- js/utils/environmentDetector.js | 44 +- js/utils/logger.js | 51 +- js/utils/markdownExporter.js | 83 +- js/views/legacyViewBundle.js | 230 ++- 21 files changed, 3592 insertions(+), 2164 deletions(-) diff --git a/js/app.js b/js/app.js index 65b9ceef..bd7fc6f4 100644 --- a/js/app.js +++ b/js/app.js @@ -13,17 +13,14 @@ class ExamSystemApp { this.state = { // 考试相关状态 exam: { - index: [], currentCategory: 'all', currentExamType: 'all', filteredExams: [], - configurations: {}, - activeConfigKey: 'exam_index' + configurations: {} }, // 练习相关状态 practice: { - records: [], selectedRecords: new Set(), bulkDeleteMode: false, dataCollector: null @@ -42,7 +39,6 @@ class ExamSystemApp { // 组件实例 components: { - dataIntegrityManager: null, pdfHandler: null, browseStateManager: null, practiceListScroller: null @@ -79,62 +75,6 @@ class ExamSystemApp { const current = this.getState(path); this.setState(path, { ...current, ...updates }); }, - async persistState(path, storageKey = null) { - const value = this.getState(path); - const key = storageKey || path.replace('.', '_'); - try { - const serializedValue = StateSerializer.serialize(value); - await storage.set(key, serializedValue); - } catch (error) { - console.error(`[App] 持久化状态失败 ${path}:`, error); - } - }, - async persistMultipleState(mapping) { - const promises = Object.entries(mapping).map(([path, storageKey]) => - this.persistState(path, storageKey) - ); - try { - await Promise.all(promises); - } catch (error) { - console.error('[App] 批量持久化状态失败:', error); - } - }, - async loadState(path, storageKey = null) { - const key = storageKey || path.replace('.', '_'); - try { - const value = await storage.get(key, null); - if (value !== null) { - const deserializedValue = StateSerializer.deserialize(value); - this.setState(path, deserializedValue); - return deserializedValue; - } - } catch (error) { - console.error(`[App] 加载状态失败 ${path}:`, error); - } - return null; - }, - async loadPersistedState() { - const stateMappings = { - exam: 'app_exam_state', - practice: 'app_practice_state', - ui: 'app_ui_state', - system: 'app_system_state' - }; - for (const [path, storageKey] of Object.entries(stateMappings)) { - await this.loadState(path, storageKey); - } - console.log('[App] 持久化状态加载完成'); - }, - async saveAllState() { - const stateMappings = { - exam: 'app_exam_state', - practice: 'app_practice_state', - ui: 'app_ui_state', - system: 'app_system_state' - }; - await this.persistMultipleState(stateMappings); - console.log('[App] 所有状态已保存'); - }, async checkComponents() { console.log('=== 组件加载检查 ==='); try { @@ -173,12 +113,9 @@ class ExamSystemApp { console.log(`${name}: ${status}`); }); console.log('\n=== 数据检查 ==='); - const practiceRecordsCount = this.getState('practice.records')?.length || 0; - console.log(`practiceRecords: ${practiceRecordsCount} 条记录`); try { - const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function' - ? await window.PracticeRecordAPI.list() - : []; + // 只统计条数,light 投影即可,避免为诊断日志拉取全量答题详情。 + const records = await window.AppData.practice.list({ projection: 'light' }); const count = Array.isArray(records) ? records.length : 0; console.log(`canonical practice records: ${count} 条记录`); } catch (_) { @@ -205,11 +142,6 @@ class ExamSystemApp { console.warn('[App] AppStateService connect failed:', error); } } - Object.defineProperty(window, 'dataIntegrityManager', { - get: () => this.state.components.dataIntegrityManager, - set: (value) => this.setState('components.dataIntegrityManager', value), - configurable: true - }); Object.defineProperty(window, 'pdfHandler', { get: () => this.state.components.pdfHandler, set: (value) => this.setState('components.pdfHandler', value), @@ -226,7 +158,7 @@ class ExamSystemApp { const integratedBootstrapMixin = { checkDependencies() { - const requiredGlobals = ['storage']; + const requiredGlobals = ['AppData']; const missing = requiredGlobals.filter((name) => !window[name]); if (missing.length > 0) { throw new Error(`Missing required dependencies: ${missing.join(', ')}`); @@ -253,6 +185,11 @@ class ExamSystemApp { }, async initializeCoreComponents() { if (this.instantiatePracticeRecorder()) { + // PracticeRecorder restores durable sessions asynchronously. The + // hot-upgrade rebind must run after that restore has completed; + // otherwise the recovery snapshot can overwrite the host session + // that we are about to seed. + await this._practiceRecorderRebindPromise; return; } console.warn('[App] PracticeRecorder类不可用,使用降级记录器'); @@ -265,14 +202,119 @@ class ExamSystemApp { return false; } try { - this.components.practiceRecorder = new PracticeRecorder(); + const previous = this.components && this.components.practiceRecorder + ? this.components.practiceRecorder + : null; + if (previous && previous.constructor === window.PracticeRecorder && previous.isFallback !== true) { + return true; + } + const recorder = new PracticeRecorder(); + this.components.practiceRecorder = recorder; this.ensurePracticeRecorderEvents(); + // Hot-upgrade from the bootstrap fallback must re-seed live host sessions; + // otherwise PRACTICE_COMPLETE finds no activeSessions and production rejects + // synthetic saves, so the child never receives PRACTICE_SUBMIT_ACK / results. + const recorderReady = recorder.ready && typeof recorder.ready.then === 'function' + ? recorder.ready + : Promise.resolve(); + this._practiceRecorderRebindPromise = Promise.resolve(recorderReady) + .then(() => this._rebindPracticeRecorderSessions(recorder, previous)) + .catch((rebindError) => { + console.warn('[App] PracticeRecorder ready 后重建活动会话失败:', rebindError); + }); return true; } catch (error) { console.error('[App] PracticeRecorder初始化失败:', error); return false; } }, + _rebindPracticeRecorderSessions(recorder, previousRecorder = null) { + if (!recorder || typeof recorder.startPracticeSession !== 'function') { + return; + } + const seeded = new Set(); + try { + if (this.examWindows && typeof this.examWindows.forEach === 'function') { + this.examWindows.forEach((info, examId) => { + if (!info || !examId) { + return; + } + if (info.reviewMode || String(info.practiceMode || '').toLowerCase() === 'memorize') { + return; + } + if (info.status === 'completed' || info.status === 'closed') { + return; + } + const sessionId = info.expectedSessionId || info.sessionId || null; + if (!sessionId) { + return; + } + try { + recorder.startPracticeSession(examId, { + sessionId: String(sessionId), + title: info.title || info.examTitle || '', + category: info.category || info.pageType || '', + frequency: info.frequency || '', + libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId') + ? info.libraryConfigurationId + : (typeof this._readLaunchLibraryConfigurationId === 'function' + ? this._readLaunchLibraryConfigurationId(examId, null, info) + : null) + }); + if (typeof recorder.handleSessionStarted === 'function') { + recorder.handleSessionStarted({ + examId, + sessionId: String(sessionId), + metadata: { + pageType: info.pageType || null, + suiteSessionId: info.suiteSessionId || null, + source: 'recorder-hot-upgrade', + libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId') + ? info.libraryConfigurationId + : null + } + }); + } + seeded.add(String(examId)); + } catch (seedError) { + console.warn('[App] 升级 PracticeRecorder 时重建活动会话失败:', examId, seedError); + } + }); + } + } catch (error) { + console.warn('[App] 升级 PracticeRecorder 时扫描 examWindows 失败:', error); + } + + // Carry over any sessions the fallback stub tracked in-memory before the class loaded. + try { + const priorSessions = previousRecorder && previousRecorder.activeSessions; + if (priorSessions && typeof priorSessions.forEach === 'function') { + priorSessions.forEach((session, examId) => { + if (!examId || seeded.has(String(examId)) || !session) { + return; + } + const sessionId = session.sessionId || session.id || null; + if (!sessionId) { + return; + } + try { + recorder.startPracticeSession(examId, Object.assign({}, session.metadata || {}, { + sessionId: String(sessionId), + title: session.metadata && (session.metadata.examTitle || session.metadata.title) || '', + totalQuestions: session.progress && session.progress.totalQuestions || 0, + libraryConfigurationId: session.metadata && session.metadata.libraryConfigurationId != null + ? session.metadata.libraryConfigurationId + : null + })); + } catch (seedError) { + console.warn('[App] 升级 PracticeRecorder 时迁移降级会话失败:', examId, seedError); + } + }); + } + } catch (error) { + console.warn('[App] 升级 PracticeRecorder 时读取降级会话失败:', error); + } + }, ensurePracticeRecorderEvents() { if (this._practiceRecorderEventsBound) { return; @@ -282,36 +324,62 @@ class ExamSystemApp { } }, createFallbackRecorder() { - function normalizeRecords(records) { - return Array.isArray(records) ? records : []; - } + const activeSessions = new Map(); + const start = (examId, examData = {}) => { + const sessionId = (examData && examData.sessionId) + || `fallback_${examId || 'exam'}_${Date.now()}`; + const session = { + examId: examId || '', + startTime: new Date().toISOString(), + sessionId, + status: 'started', + progress: { + totalQuestions: examData && examData.totalQuestions || 0 + }, + metadata: { + examTitle: examData && examData.title || '', + category: examData && examData.category || '', + frequency: examData && examData.frequency || '', + libraryConfigurationId: examData && examData.libraryConfigurationId != null + ? examData.libraryConfigurationId + : null + } + }; + if (examId) { + activeSessions.set(examId, session); + } + return session; + }; return { - startPracticeSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }), - startSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }), + activeSessions, + isFallback: true, + startPracticeSession: start, + startSession: start, + handleSessionStarted: (data) => { + if (!data || !data.examId || !data.sessionId) { + return; + } + const existing = activeSessions.get(data.examId) || { + examId: data.examId, + startTime: new Date().toISOString(), + status: 'started', + metadata: {} + }; + existing.sessionId = data.sessionId; + existing.status = 'active'; + if (data.metadata) { + existing.metadata = Object.assign({}, existing.metadata || {}, data.metadata); + } + activeSessions.set(data.examId, existing); + }, handleRealPracticeData: async () => null, savePracticeRecord: async (record) => { - try { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.saveRecord === 'function') { - await window.PracticeRecordAPI.saveRecord(record); - } else { - throw new Error('统一练习记录存储未就绪'); - } - } catch (error) { - console.warn('[App] 降级记录器保存失败:', error); - } - return record || null; + const receipt = await window.AppData.practice.completeAttempt({ record }); + return receipt && receipt.record ? receipt.record : null; }, - getPracticeRecords: async () => { - try { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return normalizeRecords(await window.PracticeRecordAPI.list()); - } - return []; - } catch (error) { - console.warn('[App] 降级记录器读取失败:', error); - return []; - } - } + // 兼容用的记录列表读取:调用方只做列表/统计展示,light 投影已覆盖, + // 不需要拉取答题详情、笔记与高亮等重负载字段。 + getPracticeRecords: async () => window.AppData.practice.list({ projection: 'light' }) }; }, schedulePracticeRecorderUpgrade(maxAttempts = 20, interval = 500) { @@ -637,11 +705,17 @@ class ExamSystemApp { case 'browse': if (window.__pendingBrowseFilter && typeof window.applyBrowseFilter === 'function') { const { category, type, filterMode, path } = window.__pendingBrowseFilter; - try { - window.applyBrowseFilter(category, type, filterMode, path); - } finally { - delete window.__pendingBrowseFilter; - } + Promise.resolve( + typeof window.initializeBrowseView === 'function' + ? window.initializeBrowseView({ skipLoad: true }) + : null + ).then(() => window.applyBrowseFilter(category, type, filterMode, path)) + .catch((error) => { + console.warn('[App] 应用待处理题库筛选失败:', error); + }) + .finally(() => { + delete window.__pendingBrowseFilter; + }); } else if (typeof window.initializeBrowseView === 'function') { window.initializeBrowseView(); } @@ -652,6 +726,9 @@ class ExamSystemApp { .then(() => (typeof window.ensureBrowseGroup === 'function' ? window.ensureBrowseGroup() : null)) .then(() => (typeof window.ensurePracticeSuiteReady === 'function' ? window.ensurePracticeSuiteReady() : null)) .then(() => { + if (typeof window.ensurePracticeRecordsSync === 'function') { + return window.ensurePracticeRecordsSync('practice-view'); + } if (typeof window.syncPracticeRecords === 'function') { return window.syncPracticeRecords(); } @@ -681,6 +758,7 @@ class ExamSystemApp { } }, browseCategory(category, type = null, filterMode = null, path = null) { + const wasAlreadyInBrowse = this.currentView === 'browse'; try { window.__pendingBrowseFilter = { category, type, filterMode, path }; const descriptor = Object.getOwnPropertyDescriptor(window, '__browseFilter'); @@ -695,14 +773,16 @@ class ExamSystemApp { } catch (_) {} this.navigateToView('browse'); try { - if (typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) { + // 非 browse → browse 时,onViewActivated 已经消费 pending filter; + // 只有原本就在 browse 页时才需要补一次应用,避免双重加载。 + if (wasAlreadyInBrowse && typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) { window.applyBrowseFilter(category, type, filterMode, path); delete window.__pendingBrowseFilter; } } catch (_) {} }, async startCategoryPractice(category) { - const examIndex = await storage.get('exam_index', []); + const examIndex = await window.resolveActiveLibraryIndex(); const categoryExams = examIndex.filter((exam) => exam.category === category); if (categoryExams.length === 0) { window.showMessage(`${category} 分类暂无可用题目`, 'warning'); @@ -726,8 +806,6 @@ class ExamSystemApp { this.checkDependencies(); this.updateLoadingMessage('正在初始化状态管理...'); this.initializeGlobalCompatibility(); - this.updateLoadingMessage('正在加载持久化状态...'); - await this.loadPersistedState(); this.updateLoadingMessage('正在初始化响应式功能...'); this.initializeResponsiveFeatures(); this.updateLoadingMessage('正在加载系统组件...'); @@ -928,20 +1006,13 @@ class ExamSystemApp { }, async loadInitialData() { try { - const examIndex = await storage.get('exam_index', []); - if (Array.isArray(examIndex)) { - this.setState('exam.index', examIndex); - } - const practiceRecords = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function' - ? await window.PracticeRecordAPI.list() - : []; - if (Array.isArray(practiceRecords)) { - this.setState('practice.records', practiceRecords); - } - const browseFilter = await storage.get('browse_filter', { category: 'all', type: 'all' }); + const browsePreference = await window.AppData.preferences.getBrowse(); + const browseFilter = browsePreference && browsePreference.filter + ? browsePreference.filter + : { category: 'all', type: 'all' }; this.setState('ui.browseFilter', browseFilter); await this.loadUserStats(); - this.updateOverviewStats(); + await this.updateOverviewStats(); } catch (error) { console.error('Failed to load initial data:', error); } @@ -957,15 +1028,15 @@ class ExamSystemApp { lastPracticeDate: null, achievements: [] }; - const stats = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function' - ? await window.PracticeRecordAPI.readStats({ fallback }) - : fallback; + const stats = Object.assign({}, fallback, await window.AppData.practice.getStats()); this.userStats = stats; return stats; }, async updateOverviewStats() { - const examIndex = this.getState('exam.index') || []; - const practiceRecords = this.getState('practice.records') || []; + const [examIndex, practiceRecords] = await Promise.all([ + window.resolveActiveLibraryIndex(), + window.AppData.practice.list({ projection: 'light' }) + ]); if (!Array.isArray(examIndex) || !Array.isArray(practiceRecords)) { console.warn('[App] 状态管理中的数据格式异常'); return; @@ -1003,7 +1074,7 @@ class ExamSystemApp { }, updateCategoryStats(examIndex, practiceRecords) { const categories = ['P1', 'P2', 'P3']; - const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []); + const list = Array.isArray(examIndex) ? examIndex : []; categories.forEach((category) => { const categoryExams = list.filter((exam) => exam.category === category); const categoryRecords = practiceRecords.filter((record) => { @@ -1091,7 +1162,12 @@ class ExamSystemApp { }, onStartEndless() { if (window.AppActions && typeof window.AppActions.startEndlessPractice === 'function') { - window.AppActions.startEndlessPractice(); + Promise.resolve(window.AppActions.startEndlessPractice()).catch((error) => { + console.error('[App] 无尽模式启动失败:', error); + if (typeof window.showMessage === 'function') { + window.showMessage('无尽模式启动失败,请稍后重试', 'error'); + } + }); return; } if (typeof window.showMessage === 'function') { @@ -1127,12 +1203,6 @@ class ExamSystemApp { } }, destroy() { - this.persistMultipleState({ - 'exam.index': 'exam_index', - 'ui.browseFilter': 'browse_filter', - 'exam.currentCategory': 'current_category', - 'exam.currentExamType': 'current_exam_type' - }); window.removeEventListener('resize', this.handleResize); if (this.sessionMonitorInterval) { clearInterval(this.sessionMonitorInterval); diff --git a/js/app/examActions.js b/js/app/examActions.js index 8c6fc821..a7c666ce 100644 --- a/js/app/examActions.js +++ b/js/app/examActions.js @@ -352,15 +352,9 @@ return categories[Math.max(0, stageIndex)] || null; } - function findExamById(examId) { - const list = Array.isArray(global.examIndex) - ? global.examIndex - : (global.appStateService && typeof global.appStateService.getExamIndex === 'function' - ? global.appStateService.getExamIndex() - : []); - return Array.isArray(list) - ? list.find((item) => item && String(item.id) === String(examId)) - : null; + function findExamById(examId, examIndex) { + const list = Array.isArray(examIndex) ? examIndex : []; + return list.find((item) => item && String(item.id) === String(examId)) || null; } function isReadingMemorizeBrowseMode() { @@ -409,8 +403,11 @@ return (Array.isArray(exams) ? exams : []).filter(isReadingMemorizeExam); } - function launchReadingMemorizeExam(examId) { - const exam = findExamById(examId); + async function launchReadingMemorizeExam(examId, examIndex = null) { + const list = Array.isArray(examIndex) + ? examIndex + : await global.resolveActiveLibraryIndex(); + const exam = findExamById(examId, list); if (!isReadingMemorizeExam(exam)) { if (typeof global.showMessage === 'function') { global.showMessage('该题目无法使用统一阅读页背题,请选择有 HTML 数据的阅读题。', 'warning'); @@ -701,13 +698,16 @@ } } - function handleCustomSuiteSelect(examId) { + async function handleCustomSuiteSelect(examId, examIndex = null) { const draft = getCustomSuiteDraft(); if (!draft || draft.status === 'ready') { return false; } - const exam = findExamById(examId); + const list = Array.isArray(examIndex) + ? examIndex + : await global.resolveActiveLibraryIndex(); + const exam = findExamById(examId, list); if (!exam) { return false; } @@ -808,7 +808,7 @@ /** * 加载并渲染题库列表 */ - function loadExamList() { + function loadExamList(examIndex = []) { console.log('[ExamActions] loadExamList called'); if (typeof global.setupBrowseControls === 'function') { @@ -828,13 +828,13 @@ if (!memorizeSelectionActive && global.__browseFilterMode && global.__browseFilterMode !== 'default' && global.browseController) { try { if (!global.browseController.buttonContainer) { - global.browseController.initialize('type-filter-buttons'); + global.browseController.initialize('type-filter-buttons', examIndex); } if (global.browseController.currentMode !== global.__browseFilterMode) { - global.browseController.setMode(global.__browseFilterMode); + global.browseController.setMode(global.__browseFilterMode, examIndex); } else { const activeFilter = global.browseController.activeFilter || 'all'; - global.browseController.applyFilter(activeFilter); + global.browseController.applyFilter(activeFilter, examIndex); } return; } catch (error) { @@ -842,15 +842,8 @@ } } - // 2. 获取题库快照 - let examIndexSnapshot = []; - if (global.appStateService) { - examIndexSnapshot = global.appStateService.getExamIndex(); - } else if (typeof global.getExamIndexState === 'function') { - examIndexSnapshot = global.getExamIndexState(); - } else { - examIndexSnapshot = Array.isArray(global.examIndex) ? global.examIndex : []; - } + // 2. 使用控制器边界传入的本次题库快照。 + const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : []; // 3. 获取筛选条件 let activeCategory = 'all'; @@ -1320,46 +1313,12 @@ return Promise.resolve(); } - function ensureSettingsToolsReady() { - if (global.AppLazyLoader && typeof global.AppLazyLoader.ensureGroup === 'function') { - return global.AppLazyLoader.ensureGroup('settings-tools'); - } - return ensureBrowseGroupReady(); - } - - async function ensureDataIntegrityManagerReady() { - try { - await ensureSettingsToolsReady(); - } catch (error) { - console.warn('[ExamActions] 设置工具预加载失败,继续尝试导出:', error); - } - - if (!global.dataIntegrityManager && global.DataIntegrityManager) { - try { - global.dataIntegrityManager = new global.DataIntegrityManager(); - } catch (error) { - console.warn('[ExamActions] 初始化 DataIntegrityManager 失败:', error); - } - } - - return global.dataIntegrityManager || null; - } - async function exportPracticeData() { try { - if (global.dataIntegrityManager && typeof global.dataIntegrityManager.exportData === 'function') { - global.dataIntegrityManager.exportData(); - try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { } - return; - } - } catch (_) { } - try { - var records = global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function' - ? await global.PracticeRecordAPI.list() - : (global.getPracticeRecordsState ? global.getPracticeRecordsState() : []); - var blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json; charset=utf-8' }); + var snapshot = await global.AppData.backups.export({ domains: ['practice'] }); + var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' }); var url = URL.createObjectURL(blob); - var a = document.createElement('a'); a.href = url; a.download = 'practice-records.json'; + var a = document.createElement('a'); a.href = url; a.download = 'ielts-atlas-practice-v2.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { } @@ -1370,14 +1329,17 @@ } async function exportAllData() { - var manager = null; try { - manager = await ensureDataIntegrityManagerReady(); - if (manager && typeof manager.exportData === 'function') { - await manager.exportData(); - try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { } - return; - } + var snapshot = await global.AppData.backups.export(); + var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' }); + var url = URL.createObjectURL(blob); + var anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'; + document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); + try { await global.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (historyError) { console.warn('[ExamActions] 导出历史记录失败:', historyError); } + try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { } + return snapshot; } catch (error) { console.error('[ExamActions] 数据导出失败:', error); if (typeof global.showMessage === 'function') { @@ -1386,9 +1348,7 @@ return; } - if (typeof global.exportPracticeData === 'function') { - return global.exportPracticeData(); - } + return null; if (typeof global.showMessage === 'function') { global.showMessage('Data manager module is unavailable.', 'warning'); } @@ -1449,7 +1409,8 @@ isReadingMemorizeExam }; - global.loadExamList = loadExamList; + // 全局 loadExamList 由 main.js 的适配器持有(无参时自解析题库索引); + // 此处仅通过 global.ExamActions.loadExamList 暴露,避免覆盖后无参调用拿到空数组。 global.resetBrowseViewToAll = resetBrowseViewToAll; global.displayExams = displayExams; global.setupExamActionHandlers = setupExamActionHandlers; diff --git a/js/app/main-entry.js b/js/app/main-entry.js index cd3a420c..163b8bb6 100644 --- a/js/app/main-entry.js +++ b/js/app/main-entry.js @@ -7,8 +7,9 @@ var SESSION_GROUP = 'session-suite'; var STATE_CORE_GROUP = 'state-core'; var SETTINGS_GROUP = 'settings-tools'; - var READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1'; var READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/; + var readingCandidateCodeCache = { mode: 'auto', customCode: '' }; + var readingCandidateCodeReady = null; function ensureLazyGroup(name) { if (!name || !global.AppLazyLoader || typeof global.AppLazyLoader.ensureGroup !== 'function') { @@ -46,34 +47,32 @@ } function readReadingCandidateCodePreferences() { - try { - var raw = global.localStorage && global.localStorage.getItem(READING_CANDIDATE_CODE_PREF_KEY); - var parsed = raw ? JSON.parse(raw) : null; - var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto'; - var customCode = parsed && typeof parsed.customCode === 'string' - ? parsed.customCode.replace(/\D/g, '').slice(0, 6) - : ''; - return { - mode: mode, - customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' - }; - } catch (_) { - return { mode: 'auto', customCode: '' }; - } - } - - function saveReadingCandidateCodePreferences(preferences) { + return Object.assign({}, readingCandidateCodeCache); + } + + function loadReadingCandidateCodePreferences() { + if (readingCandidateCodeReady) return readingCandidateCodeReady; + readingCandidateCodeReady = Promise.resolve().then(async function loadCandidateCode() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getCandidateCode(); + var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto'; + var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : ''; + readingCandidateCodeCache = { mode: mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' }; + return readingCandidateCodeCache; + }); + return readingCandidateCodeReady; + } + + async function saveReadingCandidateCodePreferences(preferences) { + await loadReadingCandidateCodePreferences(); var next = { mode: preferences && preferences.mode === 'custom' ? 'custom' : 'auto', customCode: preferences && typeof preferences.customCode === 'string' ? preferences.customCode.replace(/\D/g, '').slice(0, 6) : '' }; - try { - if (global.localStorage) { - global.localStorage.setItem(READING_CANDIDATE_CODE_PREF_KEY, JSON.stringify(next)); - } - } catch (_) { } + await global.AppData.preferences.setCandidateCode(next); + readingCandidateCodeCache = next; return next; } @@ -89,7 +88,8 @@ } } - function setupReadingCandidateCodeSettings() { + async function setupReadingCandidateCodeSettings() { + await loadReadingCandidateCodePreferences(); var input = document.getElementById('reading-candidate-code-input'); var saveButton = document.getElementById('reading-candidate-code-save-btn'); var randomButton = document.getElementById('reading-candidate-code-random-btn'); @@ -144,7 +144,7 @@ setReadingCandidateCodeStatus(status, '', ''); }); - saveButton.addEventListener('click', function saveCandidateCodeSettings() { + saveButton.addEventListener('click', async function saveCandidateCodeSettings() { var mode = getSelectedMode(); var code = input.value.replace(/\D/g, '').slice(0, 6); if (mode === 'custom' && !READING_CANDIDATE_CODE_PATTERN.test(code)) { @@ -152,7 +152,7 @@ input.focus(); return; } - saveReadingCandidateCodePreferences({ mode: mode, customCode: code }); + await saveReadingCandidateCodePreferences({ mode: mode, customCode: code }); setReadingCandidateCodeStatus( status, mode === 'custom' ? '已保存自定义编码:' + code : '已保存:自动生成。', @@ -160,11 +160,11 @@ ); }); - randomButton.addEventListener('click', function generateCandidateCode() { + randomButton.addEventListener('click', async function generateCandidateCode() { var code = hashReadingCandidateCode(createReadingCandidateCodeSeed()); setSelectedMode('custom'); input.value = code; - saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code }); + await saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code }); setReadingCandidateCodeStatus(status, '已随机生成并保存:' + code, 'success'); }); @@ -183,12 +183,13 @@ } } - function setupPracticeTimerSettings() { + async function setupPracticeTimerSettings() { var manager = global.PracticeTimerPreferences; if (!manager || typeof manager.read !== 'function' || typeof manager.save !== 'function') { return; } + if (manager.ready) await manager.ready; Array.prototype.slice.call(document.querySelectorAll('.practice-timer-card[data-timer-scope]')) .forEach(function bindTimerCard(card) { var scope = String(card.dataset.timerScope || '').toLowerCase() === 'listening' @@ -244,10 +245,14 @@ setPracticeTimerStatus(status, '', ''); }); }); - saveButton.addEventListener('click', function saveTimerPreferences() { - var saved = manager.save(scope, collect()); - apply(saved); - setPracticeTimerStatus(status, '已保存', 'success'); + saveButton.addEventListener('click', async function saveTimerPreferences() { + try { + var saved = await manager.save(scope, collect()); + apply(saved); + setPracticeTimerStatus(status, '已保存', 'success'); + } catch (error) { + setPracticeTimerStatus(status, '保存失败', 'error'); + } }); apply(manager.read(scope)); @@ -343,20 +348,6 @@ return ensureLazyGroup(SETTINGS_GROUP); } - function setStorageNamespace() { - if (!global.storage || !global.storage.ready || typeof global.storage.setNamespace !== 'function') { - return; - } - global.storage.ready.then(function applyNamespace() { - global.storage.setNamespace('exam_system'); - try { - console.log('[MainEntry] 已设置存储命名空间: exam_system'); - } catch (_) { } - }).catch(function handleNamespaceError(error) { - console.error('[MainEntry] 设置命名空间失败', error); - }); - } - function initializeNavigationShell() { try { if (global.NavigationController && typeof global.NavigationController.ensure === 'function') { @@ -594,35 +585,29 @@ return active.id.replace(/-view$/, ''); } - function syncOverviewAfterIndexLoad() { - if (!global.app || typeof global.app.setState !== 'function') { - return; - } - if (typeof global.getExamIndexState !== 'function') { - return; - } - var list = global.getExamIndexState(); + function syncOverviewAfterIndexLoad(index) { + var list = Array.isArray(index) ? index : []; if (!Array.isArray(list)) { return; } try { - global.app.setState('exam.index', list.slice()); - if (typeof global.app.refreshOverviewData === 'function') { - global.app.refreshOverviewData(); + if (typeof global.updateOverview === 'function') { + global.updateOverview(list); } } catch (error) { console.warn('[MainEntry] 同步总览数据失败:', error); } } - function handleExamIndexLoaded() { - syncOverviewAfterIndexLoad(); + function handleExamIndexLoaded(index) { + var snapshot = Array.isArray(index) ? index : []; + syncOverviewAfterIndexLoad(snapshot); var activeView = getActiveViewName(); if (activeView === 'browse') { ensureBrowseGroup().then(function afterBrowseReady() { if (typeof global.loadExamList === 'function') { - try { global.loadExamList(); } catch (_) { } + try { global.loadExamList(snapshot); } catch (_) { } } var loading = document.querySelector('#browse-view .loading'); if (loading) { @@ -636,8 +621,8 @@ if (activeView === 'practice') { Promise.all([ensureBrowseGroup(), ensurePracticeSuiteGroup()]).then(function onPracticeReady() { - if (typeof global.updatePracticeView === 'function') { - try { global.updatePracticeView(); } catch (_) { } + if (typeof global.startPracticeRecordsSyncInBackground === 'function') { + global.startPracticeRecordsSyncInBackground('exam-index-loaded', { forceRender: true }); } }).catch(function handlePracticeLoadError(error) { console.error('[MainEntry] practice 视图模块加载失败:', error); @@ -645,8 +630,8 @@ } } - global.addEventListener('examIndexLoaded', function onExamIndexLoaded() { - handleExamIndexLoaded(); + global.addEventListener('examIndexLoaded', function onExamIndexLoaded(event) { + handleExamIndexLoaded(event && event.detail ? event.detail.index : []); }); global.addEventListener('appCoreReady', function onAppCoreReady() { @@ -677,7 +662,6 @@ } function init() { - setStorageNamespace(); initializeNavigationShell(); setupReadingCandidateCodeSettings(); setupPracticeTimerSettings(); diff --git a/js/app/spellingErrorCollector.js b/js/app/spellingErrorCollector.js index dbc0d610..0237471e 100644 --- a/js/app/spellingErrorCollector.js +++ b/js/app/spellingErrorCollector.js @@ -61,12 +61,11 @@ // 错误缓存,用于临时存储检测到的错误 this.errorCache = new Map(); - // 词表存储键配置 - this.storageKeys = { - p1: 'vocab_list_p1_errors', - p4: 'vocab_list_p4_errors', - master: 'vocab_list_master_errors', - custom: 'vocab_list_custom' + this.collectionIds = { + p1: 'spelling-errors-p1', + p4: 'spelling-errors-p4', + master: 'spelling-errors-master', + custom: 'custom' }; this.lexiconCache = null; @@ -84,17 +83,8 @@ */ async init() { try { - // 等待存储系统就绪 - if (window.storage && window.storage.ready) { - await window.storage.ready; - } - - // 设置命名空间 - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[SpellingErrorCollector] 存储命名空间已设置'); - } - + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; this.initialized = true; console.log('[SpellingErrorCollector] 初始化完成'); } catch (error) { @@ -462,14 +452,9 @@ try { await this.ensureInitialized(); - const storageKey = this.storageKeys[listId] || listId; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return null; - } - - const list = await window.storage.get(storageKey); + const collectionId = this.collectionIds[listId] || listId; + const collections = await window.AppData.vocab.listCollections(); + const list = collections[collectionId]; const normalizedList = this.normalizeVocabListShape(list, listId, listId); if (normalizedList) { @@ -481,7 +466,7 @@ return null; } catch (error) { console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error); - return null; + throw error; } } @@ -493,31 +478,10 @@ async saveVocabList(vocabList) { try { await this.ensureInitialized(); - - if (!vocabList || !vocabList.id) { - console.error('[SpellingErrorCollector] 无效的词表对象'); - return false; - } - - if (!Array.isArray(vocabList.words)) { - vocabList.words = []; - } - - vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; - - // 更新统计信息 - vocabList.stats = vocabList.stats || {}; - vocabList.stats.totalWords = vocabList.words.length; - vocabList.updatedAt = Date.now(); - - const storageKey = this.storageKeys[vocabList.id] || vocabList.id; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return false; - } - - await window.storage.set(storageKey, vocabList); + vocabList = this.prepareVocabList(vocabList); + if (!vocabList) return false; + const collectionId = this.collectionIds[vocabList.id] || vocabList.id; + await window.AppData.vocab.saveCollection(collectionId, vocabList); console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`); return true; @@ -527,6 +491,19 @@ } } + prepareVocabList(vocabList) { + if (!vocabList || !vocabList.id) { + console.error('[SpellingErrorCollector] 无效的词表对象'); + return null; + } + if (!Array.isArray(vocabList.words)) vocabList.words = []; + const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; + normalized.stats = normalized.stats || {}; + normalized.stats.totalWords = normalized.words.length; + normalized.updatedAt = Date.now(); + return normalized; + } + /** * 获取词表单词数量 * @param {string} listId - 词表ID @@ -538,7 +515,7 @@ return list ? list.words.length : 0; } catch (error) { console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error); - return 0; + throw error; } } @@ -1108,17 +1085,25 @@ try { await this.ensureInitialized(); await this.ensureCoreLexicon(); - - // 按来源分组错误 const errorsBySource = this.groupErrorsBySource(errors); - - // 保存到各个来源的词表 + const pendingCollections = {}; for (const [source, sourceErrors] of Object.entries(errorsBySource)) { - await this.saveErrorsToList(source, sourceErrors); + let vocabList = await this.loadVocabList(source); + if (!vocabList) vocabList = this.createEmptyList(source, source); + this.mergeErrorsToList(vocabList, sourceErrors); + const prepared = this.prepareVocabList(vocabList); + if (!prepared) throw new Error(`生成 ${source} 错词词表失败`); + pendingCollections[this.collectionIds[source] || source] = prepared; } - - // 同步到综合词表 - await this.syncToMasterList(errors); + + let masterList = await this.loadVocabList('master'); + if (!masterList) masterList = this.createEmptyList('master', 'all'); + this.mergeErrorsToList(masterList, errors); + const preparedMaster = this.prepareVocabList(masterList); + if (!preparedMaster) throw new Error('生成综合错词词表失败'); + pendingCollections[this.collectionIds.master] = preparedMaster; + + await window.AppData.vocab.saveCollections(pendingCollections); console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`); return true; @@ -1269,7 +1254,9 @@ ); if (vocabList.words.length < originalLength) { - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`); return true; } else { @@ -1299,7 +1286,9 @@ vocabList.words = []; vocabList.updatedAt = Date.now(); - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 清空词表: ${listId}`); return true; diff --git a/js/boot-fallbacks.js b/js/boot-fallbacks.js index 8d4dd6d8..66f8531a 100644 --- a/js/boot-fallbacks.js +++ b/js/boot-fallbacks.js @@ -37,7 +37,6 @@ }); } - var storage = window.storage; // Fallback for navigation if (typeof window.showView !== 'function') { window.showView = function (viewName, resetCategory) { @@ -98,7 +97,6 @@ if (normalized === 'practice' && typeof window.ensurePracticeRecordsSync === 'function') { window.ensurePracticeRecordsSync('practice-view').catch(function () { }); } - if (normalized === 'practice' && typeof window.updatePracticeView === 'function') window.updatePracticeView(); }; } @@ -142,53 +140,24 @@ return fn.name === 'lazyProxy' || src.indexOf('ensureLazyGroup') !== -1 || src.indexOf('AppLazyLoader') !== -1; }; - function _ensureFallbackDataIntegrityManager() { - if (!window.dataIntegrityManager && window.DataIntegrityManager) { - try { - window.dataIntegrityManager = new window.DataIntegrityManager(); - } catch (error) { - console.warn('[Fallback] 初始化 DataIntegrityManager 失败:', error); - } - } - return window.dataIntegrityManager || null; + function _fallbackDownloadJson(data, filename) { + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json; charset=utf-8' }); + var url = URL.createObjectURL(blob); + var anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); } - var _fallbackDataIntegrityLoadPromise = null; - - function _ensureFallbackDataIntegrityManagerAsync() { - var manager = _ensureFallbackDataIntegrityManager(); - if (manager) { - return Promise.resolve(manager); - } - - if (!_fallbackDataIntegrityLoadPromise) { - if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { - _fallbackDataIntegrityLoadPromise = window.AppLazyLoader.ensureGroup('settings-tools'); - } else if (typeof document !== 'undefined' && !window.DataIntegrityManager) { - _fallbackDataIntegrityLoadPromise = new Promise(function (resolve, reject) { - var script = document.createElement('script'); - script.src = 'js/components/DataIntegrityManager.js'; - script.onload = resolve; - script.onerror = function (error) { - reject(error || new Error('failed to load DataIntegrityManager')); - }; - document.head.appendChild(script); - }); - } else { - _fallbackDataIntegrityLoadPromise = Promise.resolve(); - } - } - - return _fallbackDataIntegrityLoadPromise.then(function () { - var readyManager = _ensureFallbackDataIntegrityManager(); - if (!readyManager) { - throw new Error('数据管理模块未初始化'); - } - return readyManager; - }).catch(function (error) { - _fallbackDataIntegrityLoadPromise = null; - throw error; - }); + async function _fallbackExportAllData() { + await window.AppData.ready; + var snapshot = await window.AppData.backups.export(); + _fallbackDownloadJson(snapshot, 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'); + try { await window.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (error) { console.warn('[Fallback] 导出历史记录失败:', error); } + return snapshot; } function _fallbackCreateElement(tag, attributes, children) { @@ -291,21 +260,13 @@ return; } - var manager = null; - try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - if (!confirm('确定要恢复备份 ' + backupId + ' 吗?当前数据将被覆盖。')) { return; } try { window.showMessage && window.showMessage('正在恢复备份...', 'info'); - await manager.restoreBackup(backupId); + await window.AppData.backups.restore(backupId); window.showMessage && window.showMessage('备份恢复成功', 'success'); setTimeout(function () { try { @@ -384,30 +345,6 @@ }; } - var ensureDataBackupManager = (function () { - let loading = null; - return function ensureDataBackupManager() { - if (window.DataBackupManager) { - return Promise.resolve(new window.DataBackupManager()); - } - if (loading) { - return loading.then(() => new window.DataBackupManager()); - } - if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { - loading = window.AppLazyLoader.ensureGroup('settings-tools'); - return loading.then(() => new window.DataBackupManager()); - } - loading = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'js/utils/dataBackupManager.js'; - script.onload = () => resolve(); - script.onerror = (err) => reject(err || new Error('failed to load dataBackupManager')); - document.head.appendChild(script); - }); - return loading.then(() => new window.DataBackupManager()); - }; - })(); - function showImportModeModal(onSelect) { const overlay = document.createElement('div'); overlay.className = 'import-mode-overlay-lite'; @@ -431,7 +368,7 @@ const defs = [ { mode: 'merge', icon: '📥', title: '增量导入', text: '合并新数据,保留现有记录。适合日常更新。' }, - { mode: 'replace', icon: '⚠️', title: '覆盖导入', text: '清空并替换所有记录。慎用,数据不可恢复。' } + { mode: 'replace', icon: '⚠️', title: '覆盖练习记录', text: '仅用文件中的练习记录替换现有记录;提交前会显示删除数量。' } ]; defs.forEach((def) => { @@ -594,12 +531,29 @@ return; } try { - const manager = await ensureDataBackupManager(); - const result = await manager.importPracticeData(data, { - mergeMode: mode === 'replace' ? 'replace' : 'merge', - createBackup: true, - validateData: true + const payload = Array.isArray(data) ? { records: data } : data; + const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mode === 'replace' ? 'replace' : 'merge' }); + if (preview.destructive) { + const practice = preview.practice || {}; + const summary = [ + '这次导入会删除现有数据。', + `练习记录:现有 ${Number(practice.existingCount) || 0} 条 → 导入后 ${Number(practice.finalCount) || 0} 条`, + `将删除 ${Number(practice.removedCount) || 0} 条。` + ]; + if (Array.isArray(preview.clearedKeys) && preview.clearedKeys.length) { + summary.push(`将清空数据域:${preview.clearedKeys.join('、')}`); + } + summary.push('', '是否确认继续?'); + if (!window.confirm(summary.join('\n'))) { + window.showMessage && window.showMessage('已取消导入,现有数据未改变', 'info'); + return; + } + } + const backup = await window.AppData.backups.create({ type: 'pre-import' }); + const result = await window.AppData.backups.commitImport(preview.id, { + confirmDestructive: preview.destructive === true }); + try { await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup.id, practice: preview.practice }); } catch (historyError) { console.warn('[Fallback] 导入历史记录失败:', historyError); } window.showMessage && window.showMessage(`导入成功:新增 ${result.importedCount || 0} 条,跳过 ${result.skippedCount || 0} 条。`, 'success'); } catch (error) { console.error('[importData] failed', error); @@ -611,17 +565,8 @@ if (typeof window.exportAllData !== 'function') { window.exportAllData = async function () { - var manager = null; try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - console.error('[Fallback] 数据导出模块加载失败:', error); - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - - try { - await manager.exportData(); + await _fallbackExportAllData(); window.showMessage && window.showMessage('数据导出成功', 'success'); } catch (error) { console.error('[Fallback] 数据导出失败:', error); @@ -656,25 +601,14 @@ // Fallbacks for backup operations used by Settings if (typeof window.createManualBackup !== 'function') { window.createManualBackup = async function () { - var manager = null; try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - try { - var backup = await manager.createBackup(null, 'manual'); - if (backup && backup.external) { - window.showMessage && window.showMessage('本地存储不足,已将备份下载为文件', 'warning'); - } else { - window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success'); - } + var backup = await window.AppData.backups.create({ type: 'manual' }); + window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success'); try { if (typeof window.showBackupList === 'function') { window.showBackupList(); } } catch (_) { } } catch (error) { if (_fallbackIsQuotaExceeded(error)) { try { - await manager.exportData(); + await _fallbackExportAllData(); window.showMessage && window.showMessage('存储不足:已将数据导出为文件', 'warning'); } catch (exportErr) { window.showMessage && window.showMessage('备份失败且导出失败: ' + (exportErr && exportErr.message ? exportErr.message : exportErr), 'error'); @@ -688,18 +622,10 @@ if (typeof window.showBackupList !== 'function') { window.showBackupList = async function () { - var manager = null; - try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - _ensureFallbackBackupDelegates(); var backups = []; try { - backups = await manager.getBackupList(); + backups = await window.AppData.backups.list(); } catch (error) { console.warn('[Fallback] 获取备份列表失败:', error); window.showMessage && window.showMessage('无法获取备份列表', 'error'); @@ -798,38 +724,11 @@ async function ensureDefaultConfig() { try { - var configs = []; - if (window.storage && storage.get) { - var maybeConfigs = storage.get('exam_index_configurations', []); - configs = (maybeConfigs && typeof maybeConfigs.then === 'function') ? await maybeConfigs : maybeConfigs; - } + var configs = await window.AppData.library.listConfigurations(); if (!Array.isArray(configs)) configs = []; - var hasDefault = configs.some(function (c) { return c && c.key === 'exam_index'; }); - if (!hasDefault) { - var count = Array.isArray(window.examIndex) ? window.examIndex.length : 0; - configs.push({ name: '默认题库', key: 'exam_index', examCount: count, timestamp: Date.now() }); - if (window.storage && storage.set) { - try { - var maybeSetConfigs = storage.set('exam_index_configurations', configs); - if (maybeSetConfigs && typeof maybeSetConfigs.then === 'function') await maybeSetConfigs; - } catch (err) { - console.warn('[Fallback] 无法保存 exam_index_configurations:', err); - } - } - if (window.storage && storage.get) { - try { - var currentActive = storage.get('active_exam_index_key'); - currentActive = (currentActive && typeof currentActive.then === 'function') ? await currentActive : currentActive; - if (!currentActive && window.storage && storage.set) { - var maybeSetActive = storage.set('active_exam_index_key', 'exam_index'); - if (maybeSetActive && typeof maybeSetActive.then === 'function') await maybeSetActive; - } - } catch (activeErr) { - console.warn('[Fallback] 无法校正 active_exam_index_key:', activeErr); - } - } - } - return configs; + var activeIndex = await window.resolveActiveLibraryIndex(); + var count = Array.isArray(activeIndex) ? activeIndex.length : 0; + return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest', examCount: count }].concat(configs); } catch (e) { console.warn('[Fallback] ensureDefaultConfig 失败:', e); return []; @@ -856,23 +755,18 @@ window.showLibraryConfigListV2 = async function (options) { var configs = []; try { - configs = (window.storage && storage.get) ? await storage.get('exam_index_configurations', []) : []; + configs = await ensureDefaultConfig(); } catch (e) { configs = []; } - if (!Array.isArray(configs) || configs.length === 0) { - configs = await ensureDefaultConfig(); - } if (!Array.isArray(configs) || configs.length === 0) { if (window.showMessage) showMessage('暂无题库配置记录', 'info'); return; } - var activeKey = 'exam_index'; + var activeKey = null; try { - if (window.storage && storage.get) { - activeKey = await storage.get('active_exam_index_key', 'exam_index'); - } + activeKey = await window.AppData.library.getActive(); } catch (e) { } var containerId = options && typeof options.containerId === 'string' ? options.containerId : null; @@ -915,12 +809,14 @@ configs.forEach(function (cfg) { if (!cfg) return; var item = document.createElement('div'); - item.className = 'library-config-panel__item' + (cfg.key === activeKey ? ' library-config-panel__item--active' : ''); + var isDefault = cfg.builtIn === true; + var isActive = isDefault ? activeKey == null : cfg.key === activeKey; + item.className = 'library-config-panel__item' + (isActive ? ' library-config-panel__item--active' : ''); var info = document.createElement('div'); info.className = 'library-config-panel__info'; var titleLine = document.createElement('div'); - titleLine.textContent = (cfg.key === 'exam_index' ? '默认题库' : (cfg.name || cfg.key)); + titleLine.textContent = (isDefault ? '默认题库' : (cfg.name || cfg.key)); info.appendChild(titleLine); var meta = document.createElement('div'); @@ -938,18 +834,18 @@ switchBtn.className = 'btn btn-secondary'; switchBtn.type = 'button'; switchBtn.dataset.configAction = 'switch'; - switchBtn.dataset.configKey = cfg.key; - if (cfg.key === activeKey) switchBtn.disabled = true; + switchBtn.dataset.configKey = cfg.key || ''; + if (isActive) switchBtn.disabled = true; switchBtn.textContent = '切换'; actions.appendChild(switchBtn); - if (cfg.key !== 'exam_index') { + if (!isDefault) { var deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-warning'; deleteBtn.type = 'button'; deleteBtn.dataset.configAction = 'delete'; - deleteBtn.dataset.configKey = cfg.key; - if (cfg.key === activeKey) deleteBtn.disabled = true; + deleteBtn.dataset.configKey = cfg.key || ''; + if (isActive) deleteBtn.disabled = true; deleteBtn.textContent = '删除'; actions.appendChild(deleteBtn); } @@ -1326,29 +1222,14 @@ if (typeof window.getActiveLibraryConfigurationKey === 'function') { try { return await window.getActiveLibraryConfigurationKey(); } catch (_) { } } - if (storage && storage.get) { - try { - var maybeKey = storage.get('active_exam_index_key', 'exam_index'); - var key = (maybeKey && typeof maybeKey.then === 'function') ? await maybeKey : maybeKey; - return key || 'exam_index'; - } catch (_) { } - } - return 'exam_index'; + return window.AppData.library.getActive(); } async function _fallbackSetActiveLibraryKey(key) { - if (!key) return; if (typeof window.setActiveLibraryConfiguration === 'function') { try { await window.setActiveLibraryConfiguration(key); return; } catch (_) { } } - if (storage && storage.set) { - try { - var maybe = storage.set('active_exam_index_key', key); - if (maybe && typeof maybe.then === 'function') await maybe; - } catch (err) { - console.warn('[Fallback] 无法写入 active_exam_index_key:', err); - } - } + await window.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null); } async function _fallbackSaveLibraryConfiguration(name, key, count) { @@ -1356,51 +1237,28 @@ if (typeof window.saveLibraryConfiguration === 'function') { try { await window.saveLibraryConfiguration(name, key, count); return; } catch (_) { } } - if (storage && storage.get && storage.set) { - try { - var existing = storage.get('exam_index_configurations', []); - existing = (existing && typeof existing.then === 'function') ? await existing : existing; - if (!Array.isArray(existing)) existing = []; - var idx = existing.findIndex(function (c) { return c && c.key === key; }); - if (idx >= 0) { existing[idx] = entry; } else { existing.push(entry); } - var maybeSave = storage.set('exam_index_configurations', existing); - if (maybeSave && typeof maybeSave.then === 'function') await maybeSave; - } catch (err) { - console.warn('[Fallback] 保存题库配置失败:', err); - } - } + if (key) await window.AppData.library.updateConfiguration(entry); } async function _fallbackSaveIndexForKey(key, list) { - if (storage && storage.set) { - var maybe = storage.set(key, list); - if (maybe && typeof maybe.then === 'function') { - await maybe; - } - } else { - try { window[key] = list; } catch (_) { } - } + if (key) await window.AppData.library.import({ id: key, configuration: { id: key, key: key, name: key }, index: list }); } async function _fallbackApplyLibraryConfig(key, dataset, options) { if (typeof window.applyLibraryConfiguration === 'function') { try { return await window.applyLibraryConfiguration(key, dataset, options || {}); } catch (_) { } } - // fallback:直接刷新内存状态与UI - if (typeof window.setExamIndexState === 'function') { - try { window.setExamIndexState(dataset); } catch (_) { } - } else { - try { window.examIndex = Array.isArray(dataset) ? dataset.slice() : []; } catch (_) { } - } + var snapshot = Array.isArray(dataset) ? dataset.slice() : []; if (options && options.setActive) { await _fallbackSetActiveLibraryKey(key); } - try { if (typeof window.updateOverview === 'function') window.updateOverview(); } catch (_) { } + try { if (typeof window.updateOverview === 'function') window.updateOverview(snapshot); } catch (_) { } try { if (typeof window.loadExamList === 'function') { - window.loadExamList(); + window.loadExamList(snapshot); } } catch (_) { } + try { window.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key: key, index: snapshot } })); } catch (_) { } return true; } @@ -1611,15 +1469,7 @@ } var activeKey = await _fallbackGetActiveLibraryKey(); - var currentIndex = (typeof window.getExamIndexState === 'function') - ? window.getExamIndexState() - : (Array.isArray(window.examIndex) ? window.examIndex : []); - if (storage && storage.get) { - try { - var maybeCurrent = storage.get(activeKey, currentIndex); - currentIndex = (maybeCurrent && typeof maybeCurrent.then === 'function') ? await maybeCurrent : maybeCurrent; - } catch (_) { } - } + var currentIndex = await window.resolveActiveLibraryIndex(); if (!Array.isArray(currentIndex)) currentIndex = []; currentIndex = _fallbackNormalizeIndexForCustomConfig(currentIndex); @@ -1662,7 +1512,7 @@ }; if (mode === 'full') { - var targetKey = 'exam_index_' + Date.now(); + var targetKey = 'library_import_' + Date.now(); var configName = (type === 'reading' ? '阅读' : '听力') + '全量-' + new Date().toLocaleString(); try { await saveAndApply(targetKey, configName, true); @@ -1690,7 +1540,7 @@ } } - var targetKeyInc = 'exam_index_' + Date.now(); + var targetKeyInc = 'library_import_' + Date.now(); var configNameInc = (type === 'reading' ? '阅读' : '听力') + '增量-' + new Date().toLocaleString(); await saveAndApply(targetKeyInc, configNameInc, false); await _fallbackApplyLibraryConfig(targetKeyInc, newIndex, { setActive: true, skipConfigRefresh: false }); diff --git a/js/components/SystemDiagnostics.js b/js/components/SystemDiagnostics.js index 8044e3ad..d6b88800 100644 --- a/js/components/SystemDiagnostics.js +++ b/js/components/SystemDiagnostics.js @@ -122,8 +122,11 @@ class SystemDiagnostics { /** * 测试单个题目的通信功能 */ - async testExamCommunication(examId, timeout = 10000) { - const exam = window.examIndex?.find(e => e.id === examId); + async testExamCommunication(examId, timeout = 10000, examIndex = null) { + const index = Array.isArray(examIndex) + ? examIndex + : await window.resolveActiveLibraryIndex(); + const exam = index.find(e => e.id === examId); if (!exam) { return { examId, @@ -176,7 +179,10 @@ class SystemDiagnostics { } }; - examWindow.postMessage(testMessage, '*'); + examWindow.postMessage( + testMessage, + window.location.protocol === 'file:' ? '*' : window.location.origin + ); // 等待响应 const result = await new Promise((resolve) => { @@ -234,14 +240,17 @@ class SystemDiagnostics { /** * 批量测试通信功能 */ - async testMultipleExams(examIds, concurrency = 3) { + async testMultipleExams(examIds, concurrency = 3, examIndex = null) { console.log(`[SystemDiagnostics] 开始批量测试 ${examIds.length} 个题目的通信功能`); + const index = Array.isArray(examIndex) + ? examIndex + : await window.resolveActiveLibraryIndex(); const results = []; for (let i = 0; i < examIds.length; i += concurrency) { const batch = examIds.slice(i, i + concurrency); const batchResults = await Promise.all( - batch.map(examId => this.testExamCommunication(examId)) + batch.map(examId => this.testExamCommunication(examId, 10000, index)) ); results.push(...batchResults); } @@ -295,7 +304,7 @@ class SystemDiagnostics { connection.window.postMessage({ type: 'HEARTBEAT', timestamp: Date.now() - }, '*'); + }, window.location.protocol === 'file:' ? '*' : window.location.origin); } } catch (error) { this.handleConnectionLost(examId, 'connection_error'); @@ -523,7 +532,7 @@ class SystemDiagnostics { async fullSystemDiagnostics() { console.log('[SystemDiagnostics] 开始完整系统诊断...'); - const examIndex = window.examIndex || []; + const examIndex = await window.resolveActiveLibraryIndex(); const diagnosticReport = { timestamp: Date.now(), indexValidation: null, @@ -540,7 +549,7 @@ class SystemDiagnostics { // 如果有失败的题目,进行通信测试 if (diagnosticReport.indexValidation.failedExams.length > 0) { const failedExamIds = diagnosticReport.indexValidation.failedExams.map(exam => exam.id); - diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5)); // 限制测试数量 + diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5), 3, examIndex); // 限制测试数量 } } catch (error) { console.error('[SystemDiagnostics] 索引验证失败:', error); @@ -693,4 +702,4 @@ class SystemDiagnostics { } // 导出到全局 -window.SystemDiagnostics = SystemDiagnostics; \ No newline at end of file +window.SystemDiagnostics = SystemDiagnostics; diff --git a/js/listeningRecordBridge.js b/js/listeningRecordBridge.js index 5b96b1f7..839826e7 100644 --- a/js/listeningRecordBridge.js +++ b/js/listeningRecordBridge.js @@ -2,6 +2,20 @@ 'use strict'; var TAG = '[ListeningBridge]'; + var HOST_MESSAGE_SOURCE = 'exam_host'; + + function deriveParentOriginFromReferrer() { + try { + if (!window.document || !window.document.referrer) return ''; + var parsed = new URL(window.document.referrer, window.location.href); + // Chromium: file URL.origin is "file://", postMessage event.origin is "null". + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (e) { + return ''; + } + } var state = { sessionId: null, @@ -11,8 +25,13 @@ initialized: false, completed: false, parentWindow: null, + expectedParentOrigin: deriveParentOriginFromReferrer(), + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', initRequestTimer: null, - initRequestAttempts: 0 + initRequestAttempts: 0, + pendingCompletion: null }; function log() { @@ -37,6 +56,22 @@ return null; } + function createSubmissionId() { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return 'listening-submit-' + window.crypto.randomUUID(); + } + if (window.crypto && typeof window.crypto.getRandomValues === 'function') { + var bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return 'listening-submit-' + Array.prototype.map.call(bytes, function (byte) { + return byte.toString(16).padStart(2, '0'); + }).join(''); + } + } catch (_) {} + return 'listening-submit-' + Date.now() + '-' + Math.random().toString(36).slice(2); + } + function sendMessage(type, data) { var pw = state.parentWindow || findParentWindow(); if (!pw) { @@ -44,7 +79,17 @@ return false; } try { - pw.postMessage({ type: type, data: data || {}, source: 'listening_record_bridge', timestamp: Date.now() }, '*'); + var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) { + warn('无法 send message — trusted parent origin is unavailable'); + return false; + } + var secureData = Object.assign({}, data || {}, { + windowSessionToken: state.windowSessionToken || null + }); + pw.postMessage({ type: type, data: secureData, source: 'listening_record_bridge', timestamp: Date.now() }, targetOrigin); return true; } catch (e) { warn('postMessage failed:', e); @@ -327,30 +372,11 @@ function parseObjectLiteral(text, startIndex, label) { label = label || 'inline'; if (startIndex >= text.length) return null; - var depth = 0; - var i = startIndex; - var started = false; - var objectStart = -1; - for (; i < text.length; i++) { - var ch = text.charAt(i); - if (ch === '{') { - if (!started) objectStart = i; - depth++; - started = true; - } - else if (ch === '}') { depth--; if (started && depth === 0) break; } - else if (ch === '\'' || ch === '"') { - var quote = ch; - for (i++; i < text.length; i++) { - if (text.charAt(i) === '\\' && i + 1 < text.length) { i++; continue; } - if (text.charAt(i) === quote) break; - } - } - } - if (!started || depth !== 0) return null; - var snippet = text.substring(objectStart, i + 1); try { - return (new Function('return (' + snippet + ')'))(); + if (!window.SafeObjectLiteralParser || typeof window.SafeObjectLiteralParser.parseAt !== 'function') { + throw new Error('SafeObjectLiteralParser is unavailable'); + } + return window.SafeObjectLiteralParser.parseAt(text, startIndex).value; } catch (e) { warn('parseObjectLiteral failed for', label, e); return null; @@ -753,13 +779,35 @@ }; } + function sendPendingCompletion(reason) { + var pending = state.pendingCompletion; + if (!pending || state.completed) return false; + if (!state.initialized || !state.windowSessionToken) { + sendInitRequest(reason || 'complete_before_init'); + return false; + } + if (!pending.payload) { + pending.payload = buildBridgePayload(pending.details); + pending.payload.submissionId = pending.submissionId; + } + log( + 'sending PRACTICE_COMPLETE, submissionId=' + pending.submissionId + + ' correct=' + pending.payload.scoreInfo.correct + '/' + pending.payload.scoreInfo.total + ); + return sendMessage('PRACTICE_COMPLETE', pending.payload); + } + function onComplete(options) { options = options || {}; if (state.completed) { log('already completed, skipping'); return true; } - state.completed = true; + if (state.pendingCompletion) { + sendPendingCompletion('completion_retry'); + scheduleCompletionRetries(state.pendingCompletion.options || options); + return true; + } var allowGenerated = !!options.allowGenerated; var details = extractAttemptDetails(window, { allowGenerated: allowGenerated }); @@ -772,17 +820,17 @@ } if (!details.length) { warn('no details extracted, cannot complete'); - state.completed = false; return false; } - var payload = buildBridgePayload(details); - log('sending PRACTICE_COMPLETE, correct=' + payload.scoreInfo.correct + '/' + payload.scoreInfo.total); - if (!state.initialized) { - sendInitRequest('complete_before_init'); - } - sendMessage('PRACTICE_COMPLETE', payload); - clearCompletionRetryTimers(); + state.pendingCompletion = { + submissionId: createSubmissionId(), + details: details, + options: Object.assign({}, options), + payload: null + }; + sendPendingCompletion(state.initialized ? 'completion_created' : 'complete_before_init'); + scheduleCompletionRetries(options); return true; } @@ -802,9 +850,9 @@ for (var i = 0; i < retryDelays.length; i++) { (function (delay) { completionRetryTimers.push(setTimeout(function () { - if (!state.completed) { - onComplete(options || {}); - } + if (state.completed) return; + if (state.pendingCompletion) sendPendingCompletion('completion_timeout'); + else onComplete(options || {}); }, delay)); })(retryDelays[i]); } @@ -1023,18 +1071,71 @@ if (type === 'INIT_SESSION' || type === 'init_exam_session') { var payload = data.data || data; - if (event.source && event.source !== window && typeof event.source.postMessage === 'function') { - state.parentWindow = event.source; + if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return; + var incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : ''; + var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : ''; + if (!incomingToken) return; + var expectedParentOrigin = state.expectedParentOrigin + && state.expectedParentOrigin !== 'file://' + && String(state.expectedParentOrigin).indexOf('file:') !== 0 + ? state.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return; + state.parentOrigin = expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else if (window.location.protocol === 'file:') { + var trustedFileOrigin = incomingOrigin === 'null' + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) return; + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } else { + var trustedWebOrigin = !!incomingOrigin + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) return; + state.parentOrigin = incomingOrigin; + state.parentOriginIsOpaque = false; } + var previousSessionId = state.sessionId; + state.windowSessionToken = incomingToken; state.sessionId = payload.sessionId || state.sessionId || (state.examId + '_' + Date.now()); state.examId = payload.examId || state.examId; state.suiteSessionId = payload.suiteSessionId || state.suiteSessionId || null; state.startTime = toTimestampMs(payload.startTime, toTimestampMs(state.startTime, Date.now())); state.initialized = true; stopInitRequestLoop(); + if (state.pendingCompletion && String(previousSessionId || '') !== String(state.sessionId || '')) { + state.pendingCompletion.payload = null; + } log('INIT_SESSION received — examId=' + state.examId + ' sessionId=' + state.sessionId); sendSessionReady('ready'); + if (state.pendingCompletion) { + sendPendingCompletion('init_received'); + } + } else if (type === 'PRACTICE_SUBMIT_ACK' || type === 'PRACTICE_SUBMIT_FAILED') { + var outcome = data.data || data; + if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return; + var outcomeOrigin = typeof event.origin === 'string' ? event.origin : ''; + if (state.parentOriginIsOpaque ? outcomeOrigin !== 'null' : (!state.parentOrigin || outcomeOrigin !== state.parentOrigin)) return; + if (!outcome || String(outcome.windowSessionToken || '') !== String(state.windowSessionToken || '')) return; + var pending = state.pendingCompletion; + if (!pending + || String(outcome.submissionId || '') !== String(pending.submissionId || '') + || String(outcome.sessionId || '') !== String(state.sessionId || '')) return; + if (type === 'PRACTICE_SUBMIT_ACK') { + state.completed = true; + state.pendingCompletion = null; + clearCompletionRetryTimers(); + log('PRACTICE_COMPLETE persisted, submissionId=' + outcome.submissionId); + } else { + warn('PRACTICE_COMPLETE persistence failed, retrying submissionId=' + outcome.submissionId); + scheduleCompletionRetries(pending.options || {}); + } } }); } diff --git a/js/listeningUnifiedWrapper.js b/js/listeningUnifiedWrapper.js index ad3319a7..f5d6b798 100644 --- a/js/listeningUnifiedWrapper.js +++ b/js/listeningUnifiedWrapper.js @@ -4,8 +4,8 @@ var BRIDGE_SCRIPT_URL = '/js/bundles/listening-record-bridge.bundle.js'; var ADAPTER_STYLE_ID = 'listening-unified-wrapper-adapter-style'; var TIMER_INTERVAL_MS = 1000; - var CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1'; var CANDIDATE_CODE_PATTERN = /^\d{6}$/; + var candidateCodeCache = { mode: 'auto', customCode: '' }; var state = { examId: '', sourceUrl: '', @@ -19,7 +19,11 @@ bridgeInjected: false, bridgeReady: false, pendingMessages: [], - parentWindow: null, + parentWindow: global.opener || (global.parent && global.parent !== global ? global.parent : null), + expectedParentOrigin: '', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', timerInterval: null, lastTimerText: '' }; @@ -27,9 +31,18 @@ function sameOrigin() { return global.location && global.location.origin && global.location.origin !== 'null' ? global.location.origin - : '*'; + : (global.location && global.location.protocol === 'file:' ? '*' : ''); } + try { + if (global.document && global.document.referrer) { + var referrerUrl = new URL(global.document.referrer, global.location.href); + state.expectedParentOrigin = referrerUrl.origin && referrerUrl.origin !== 'null' + ? referrerUrl.origin + : ''; + } + } catch (_) { } + function normalizeSafeId(value, fallback) { var text = String(value || '').trim(); return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,180}$/.test(text) ? text : fallback; @@ -125,20 +138,15 @@ } function readCandidateCodePreferences() { - try { - var raw = global.localStorage && global.localStorage.getItem(CANDIDATE_CODE_PREF_KEY); - var parsed = raw ? JSON.parse(raw) : null; - var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto'; - var customCode = parsed && typeof parsed.customCode === 'string' - ? parsed.customCode.replace(/\D/g, '').slice(0, 6) - : ''; - return { - mode: mode, - customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' - }; - } catch (_) { - return { mode: 'auto', customCode: '' }; - } + return Object.assign({}, candidateCodeCache); + } + + async function loadCandidateCodePreferences() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getCandidateCode(); + var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto'; + var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : ''; + candidateCodeCache = { mode: mode, customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' }; } function resolveCandidateCode() { @@ -830,7 +838,9 @@ return; } try { - win.postMessage(message, sameOrigin()); + var targetOrigin = sameOrigin(); + if (!targetOrigin) throw new Error('iframe target origin unavailable'); + win.postMessage(message, targetOrigin); } catch (_) { state.pendingMessages.push(message); } @@ -869,35 +879,74 @@ return; } try { - target.postMessage(message, sameOrigin()); + var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) return; + target.postMessage(message, targetOrigin); } catch (_) { } } - function handleParentMessage(message, source) { - if (source && source !== global && typeof source.postMessage === 'function') { - state.parentWindow = source; - } + function handleParentMessage(event) { + var message = event && event.data; + var source = event && event.source; var type = message && message.type; if (type === 'INIT_SESSION' || type === 'init_exam_session') { var payload = message.data || message; + var incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : ''; + var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : ''; + if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host' || !incomingToken) return; + if (state.expectedParentOrigin) { + if (incomingOrigin !== state.expectedParentOrigin || declaredOrigin !== state.expectedParentOrigin) return; + state.parentOrigin = state.expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else { + if (incomingOrigin !== 'null' || declaredOrigin !== 'null' || global.location.protocol !== 'file:') return; + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } + state.windowSessionToken = incomingToken; state.examId = normalizeSafeId(payload.examId, state.examId || 'listening-unknown'); state.sessionId = normalizeSafeId(payload.sessionId, state.sessionId || (state.examId + '_' + Date.now())); state.suiteSessionId = normalizeSafeId(payload.suiteSessionId, state.suiteSessionId || ''); state.startTime = Number.isFinite(Number(payload.startTime)) ? Number(payload.startTime) : state.startTime; + } else { + var messagePayload = message && message.data || {}; + var messageOrigin = typeof event.origin === 'string' ? event.origin : ''; + var messageToken = typeof messagePayload.windowSessionToken === 'string' ? messagePayload.windowSessionToken.trim() : ''; + var originMatches = state.parentOriginIsOpaque + ? messageOrigin === 'null' + : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin); + if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host' + || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) return; } forwardToIframe(message); } function handleMessage(event) { - if (!event || !event.data || (event.origin && event.origin !== global.location.origin)) { + if (!event || !event.data) { return; } var frameWindow = getFrameWindow(); if (event.source && frameWindow && event.source === frameWindow) { + var frameOrigin = sameOrigin(); + if (frameOrigin === '*') { + if (event.origin !== 'null') return; + } else if (!frameOrigin || event.origin !== frameOrigin) { + return; + } + var framePayload = event.data && event.data.data || {}; + var permitsPreInit = event.data.type === 'REQUEST_INIT' + || (event.data.type === 'SESSION_READY' && framePayload.initialized !== true); + if (!permitsPreInit && ( + !state.windowSessionToken + || framePayload.windowSessionToken !== state.windowSessionToken + )) return; forwardToParent(event.data); return; } - handleParentMessage(event.data, event.source); + handleParentMessage(event); } function exposeCompatibilityApi() { @@ -937,7 +986,9 @@ }; } - function init() { + async function init() { + await loadCandidateCodePreferences(); + if (global.PracticeTimerPreferences && global.PracticeTimerPreferences.ready) await global.PracticeTimerPreferences.ready; var root = getRoot(); var frame = getFrame(); if (!root || !frame) { diff --git a/js/main.js b/js/main.js index 5737ca7f..1353e7d6 100644 --- a/js/main.js +++ b/js/main.js @@ -226,7 +226,11 @@ function ensureLegacyNavigation(options) { syncOnNavigate: true, onRepeatNavigate: function onRepeatNavigate(viewName) { if (viewName === 'browse') { - resetBrowseViewToAll(); + if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') { + window.ExamActions.resetBrowseViewToAll(); + } else if (typeof window.resetBrowseViewToAll === 'function') { + window.resetBrowseViewToAll(); + } } }, onNavigate: function onNavigate(viewName) { @@ -280,13 +284,6 @@ async function initializeLegacyComponents() { browseStateManager = new BrowseStateManager(); console.log('[System] 浏览状态管理器已初始化'); } - if (window.DataIntegrityManager) { - window.dataIntegrityManager = new DataIntegrityManager(); - console.log('[System] 数据完整性管理器已初始化'); - } else { - console.info('[System] DataIntegrityManager 按需加载,跳过启动初始化'); - } - // 性能优化器已拆到 diagnostics-tools;浏览页保留无依赖降级路径。 if (window.PerformanceOptimizer) { window.performanceOptimizer = new PerformanceOptimizer(); @@ -295,93 +292,47 @@ async function initializeLegacyComponents() { console.info('[System] PerformanceOptimizer 按需加载,跳过启动初始化'); } - // Clean up old cache and configurations for v1.1.0 upgrade (one-time only) - let needsCleanup = false; - try { - needsCleanup = !localStorage.getItem('upgrade_v1_1_0_cleanup_done'); - } catch (error) { - console.warn('[System] 检查升级标记失败,将继续执行清理流程', error); - needsCleanup = true; - } - - if (needsCleanup) { - console.log('[System] 首次运行,执行升级清理...'); - try { - await cleanupOldCache(); - } finally { - try { localStorage.setItem('upgrade_v1_1_0_cleanup_done', '1'); } catch (_) { } - } - } else { - console.log('[System] 升级清理已完成,跳过重复清理'); - } - // Load data and setup listeners await loadLibraryInternal(); - startPracticeRecordsSyncInBackground('boot'); // 后台静默加载练习记录,避免阻塞首页 + // 首页/题库浏览只使用摘要记录;完整 answers/realData 在进入练习历史页时再加载。 setupMessageListener(); // Listen for updates from child windows - setupStorageSyncListener(); // Listen for storage changes from other tabs } -// Clean up old cache and configurations -async function cleanupOldCache() { - try { - console.log('[System] 正在清理旧缓存与配置...'); - await storage.remove('exam_index'); - await storage.remove('active_exam_index_key'); - await storage.set('exam_index_configurations', []); - console.log('[System] 旧缓存清理完成'); - } catch (error) { - console.warn('[System] 清理旧缓存时出错:', error); - } -} - - // --- Data Loading and Management --- -// Phase 3: 练习记录同步 - 保留在 main.js(核心数据流,暂不迁移) +// Practice history is read from AppData for each refresh. Only its signature is +// retained as runtime UI state; record arrays never become a second authority. +let lastPracticeRecordsSignature = null; async function syncPracticeRecords(options = {}) { - const { forceRender = false } = options || {}; - console.log('[System] 正在从存储中同步练习记录...'); - const previousRecords = typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []); - let records = []; - let loadError = null; - try { - records = await listCanonicalPracticeRecords(); - } catch (e) { - console.warn('[System] 同步记录时发生错误:', e); - loadError = e; - records = Array.isArray(previousRecords) ? previousRecords.slice() : []; - const errorMessage = String(e && e.message ? e.message : e).toLowerCase(); - if (errorMessage.includes('not ready') || errorMessage.includes('未就绪')) { - setTimeout(() => { - try { - startPracticeRecordsSyncInBackground('api-ready-retry'); - } catch (_) { } - }, 800); - } - if (Array.isArray(previousRecords) && previousRecords.length > 0) { - console.warn('[System] canonical store 暂未就绪,保留当前内存中的练习记录,避免误清空视图。'); - } - } - - if (loadError && (!Array.isArray(records) || records.length === 0) && (!Array.isArray(previousRecords) || previousRecords.length === 0)) { - console.warn('[System] canonical store 暂未就绪,本次跳过练习记录视图刷新。'); - return; - } - - // Normalize duration and percentages to avoid 0-second artifacts + const { forceRender = false, mode = 'summary' } = options || {}; + const loadMode = mode === 'full' ? 'full' : 'summary'; + let recordsUnchanged = false; + console.log(`[System] 正在从存储中同步练习记录... (mode=${loadMode})`); + let [records, insightRecords, examIndex] = await Promise.all([ + listCanonicalPracticeRecordSummaries(), + window.AppData.practice.listInsights({ limit: 10 }), + resolveActiveExamIndex() + ]); + const insightsById = new Map((Array.isArray(insightRecords) ? insightRecords : []) + .filter((record) => record && record.id) + .map((record) => [String(record.id), record])); + records = (Array.isArray(records) ? records : []).map((record) => + record && insightsById.has(String(record.id)) + ? Object.assign({}, record, insightsById.get(String(record.id))) + : record); + if (loadMode === 'full') { + console.log('[System] mode=full 请求已限定为 light 视图刷新;完整记录请直接调用 AppData.practice.list()'); + } + + // Normalize duration and percentages to avoid 0-second artifacts(summary 无 realData/interactions) try { records = (records || []).map(r => { - const rd = (r && r.realData) || {}; let duration = (typeof r.duration === 'number') ? r.duration : undefined; if (!(Number.isFinite(duration) && duration > 0)) { - const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {}; + const sInfo = r && r.scoreInfo || {}; const candidates = [ - r.duration, rd.duration, r.durationSeconds, r.duration_seconds, + r.duration, r.durationSeconds, r.duration_seconds, r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent, - rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent, sInfo.duration, sInfo.timeSpent ]; for (const v of candidates) { @@ -395,22 +346,12 @@ async function syncPracticeRecords(options = {}) { duration = Math.round((e - s) / 1000); } } - if (!(Number.isFinite(duration) && duration > 0) && rd && Array.isArray(rd.interactions) && rd.interactions.length) { - try { - const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n)); - if (ts.length) { - const span = Math.max(...ts) - Math.min(...ts); - if (Number.isFinite(span) && span > 0) duration = Math.floor(span / 1000); - } - } catch (_) { } - } } if (!Number.isFinite(duration)) duration = 0; - // Coerce percentage/accuracy if only scoreInfo exists - const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {}; + const sInfo = r && r.scoreInfo || {}; const correct = (typeof r.correctAnswers === 'number') ? r.correctAnswers : (typeof sInfo.correct === 'number' ? sInfo.correct : (typeof r.score === 'number' ? r.score : undefined)); - const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : (rd.answers ? Object.keys(rd.answers).length : undefined)); + const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : undefined); let accuracy = (typeof r.accuracy === 'number') ? r.accuracy : undefined; let percentage = (typeof r.percentage === 'number') ? r.percentage : undefined; if ((accuracy === undefined || percentage === undefined) && Number.isFinite(correct) && Number.isFinite(total) && total > 0) { @@ -423,138 +364,71 @@ async function syncPracticeRecords(options = {}) { }); } catch (e) { console.warn('[System] normalize durations failed:', e); } - // 若数据未变则跳过 UI 刷新,避免无意义的列表重置 - // 使用轻量 listSummary 进行签名比对,无需反序列化+克隆完整记录数组 + // Avoid resetting the list when the authoritative light projection is unchanged. try { - const prev = typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []); const renderer = window.PracticeHistoryRenderer; if (renderer && renderer.helpers && typeof renderer.helpers.computeRecordsSignature === 'function') { - const prevSig = renderer.helpers.computeRecordsSignature(prev); - // 若 forceRender 则跳过轻量查询,直接走完整加载 - if (!forceRender && window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') { - const summaries = await window.PracticeRecordAPI.listSummary(); - const nextSig = renderer.helpers.computeRecordsSignature(summaries); - if (prevSig === nextSig) { - console.log('[System] 练习记录未变化,跳过UI刷新'); - return; - } - } else { - const nextSig = renderer.helpers.computeRecordsSignature(records); - if (!forceRender && prevSig === nextSig) { - console.log('[System] 练习记录未变化,跳过UI刷新'); - return; - } + const nextSignature = renderer.helpers.computeRecordsSignature(records); + if (!forceRender && lastPracticeRecordsSignature === nextSignature) { + console.log('[System] 练习记录未变化,跳过UI刷新'); + recordsUnchanged = true; } + lastPracticeRecordsSignature = nextSignature; } } catch (_) { /* 保底不中断同步流程 */ } - // 新增修复3D:确保全局变量和 app.state 都跟 canonical records 保持一致 - setPracticeRecordsState(records); - try { - if (window.app && window.app.state && window.app.state.practice) { - const nextRecords = typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(records) ? records : []); - window.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : []; - } - } catch (error) { - console.warn('[System] 同步练习记录到 App state 失败:', error); - } - refreshBrowseProgressFromRecords(records); + refreshBrowseProgressFromRecords(records, examIndex); - console.log(`[System] ${records.length} 条练习记录已加载到内存。`); - updatePracticeView(); + console.log(`[System] 已从 AppData 加载 ${records.length} 条练习摘要。`); + if (!recordsUnchanged) { + updatePracticeView(records, examIndex); + } + return records; } let practiceRecordsLoadPromise = null; -function ensurePracticeRecordsSync(trigger = 'default') { +function ensurePracticeRecordsSync(trigger = 'default', options = {}) { if (practiceRecordsLoadPromise) { return practiceRecordsLoadPromise; } const loadTask = (async () => { - await syncPracticeRecords(); - return true; - })().catch((error) => { - console.warn(`[System] 练习记录同步失败(${trigger}):`, error); - return false; - }); + return syncPracticeRecords(Object.assign({ mode: 'summary' }, options || {})); + })(); practiceRecordsLoadPromise = loadTask.finally(() => { practiceRecordsLoadPromise = null; }); return practiceRecordsLoadPromise; } -function startPracticeRecordsSyncInBackground(trigger = 'default') { - try { - ensurePracticeRecordsSync(trigger); - } catch (error) { +function startPracticeRecordsSyncInBackground(trigger = 'default', options = {}) { + ensurePracticeRecordsSync(trigger, options).catch((error) => { console.warn(`[System] 后台同步练习记录失败(${trigger}):`, error); - } + }); } async function listCanonicalPracticeRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } - - throw new Error('统一练习记录 API 未就绪'); + // 两个调用方(bulkDeleteRecords / deleteRecord)只用 id、title、date 做存在性校验与确认文案, + // light 投影已覆盖;删除本身走 AppData.practice.delete/deleteMany,不需要全量答题详情。 + const records = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(records) ? records : []; } -async function replaceCanonicalPracticeRecords(records) { - const finalRecords = Array.isArray(records) ? records : []; - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') { - await window.PracticeRecordAPI.replace(finalRecords, { - maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000 - }); - return true; - } - - throw new Error('统一练习记录 API 未就绪'); +async function listCanonicalPracticeRecordSummaries() { + const summaries = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(summaries) ? summaries : []; } -function cleanupLegacyPracticeRecordArtifacts() { - // Unprefixed legacy keys only — never the active backend key. - const legacyRawKeys = ['practice_records', 'old_prefix_practice_records']; - - try { - legacyRawKeys.forEach((key) => { - try { localStorage.removeItem(key); } catch (_) { } - try { sessionStorage.removeItem(key); } catch (_) { } - }); - } catch (error) { - console.warn('[System] 清理 legacy 练习记录影子键失败:', error); - } - - const storage = window.storage; - const shadowKey = storage && typeof storage.getKey === 'function' - ? storage.getKey('practice_records') - : null; - if (!shadowKey) { - return; +async function resolveActiveExamIndex() { + if (typeof window.resolveActiveLibraryIndex === 'function') { + const index = await window.resolveActiveLibraryIndex(); + return Array.isArray(index) ? index : []; } - - // When IndexedDB is blocked/unavailable, writePersistentValue stores canonical - // practice_records under exam_system_practice_records in localStorage/sessionStorage. - // Removing that key after replace/delete would wipe the just-persisted history. - const mode = storage && storage.mode; - const usesWebStorageBackend = mode === 'localStorage' || mode === 'sessionStorage'; - if (usesWebStorageBackend || storage.indexedDBBlocked || !storage.indexedDB) { - return; + const manager = await ensureLibraryManagerReady(); + if (manager && typeof manager.resolveActiveIndex === 'function') { + const index = await manager.resolveActiveIndex(); + return Array.isArray(index) ? index : []; } - - try { localStorage.removeItem(shadowKey); } catch (_) { } - try { sessionStorage.removeItem(shadowKey); } catch (_) { } -} - -async function persistPracticeRecordsAndRefresh(records, trigger = 'manual-update') { - const finalRecords = Array.isArray(records) ? records : []; - await replaceCanonicalPracticeRecords(finalRecords); - cleanupLegacyPracticeRecordArtifacts(); - await syncPracticeRecords({ forceRender: true }); - return getPracticeRecordsState(); + throw new Error('LibraryManager.resolveActiveIndex is unavailable'); } const completionNoticeState = { @@ -617,6 +491,30 @@ function extractCompletionSessionId(envelope) { return null; } +// fallbackExamSessions 是纯内存 Map(js/app.js:50),主页刷新后会话映射即丢失。 +// 完成消息本身携带 examId(unifiedReadingPage.buildEnvelope / practicePageEnhancer.buildResultsPayload / +// listeningRecordBridge.buildBridgePayload 都会写入),据此仍可走同一条持久化路径。 +function resolveCompletionExamId(envelope, payload) { + const sources = [payload, envelope, envelope && envelope.data]; + for (const source of sources) { + if (!source || typeof source !== 'object') { + continue; + } + const candidates = [ + source.examId, + source.derivedExamId, + source.metadata && typeof source.metadata === 'object' ? source.metadata.examId : null + ]; + for (const candidate of candidates) { + const normalized = candidate == null ? '' : String(candidate).trim(); + if (normalized) { + return normalized; + } + } + } + return null; +} + function shouldAnnounceCompletion(sessionId) { const now = Date.now(); if (sessionId && completionNoticeState.lastSessionId === sessionId) { @@ -748,6 +646,17 @@ if (typeof window !== 'undefined') { } function setupMessageListener() { + const resolveFallbackMessageOrigin = () => { + const location = window.location || {}; + const rawOrigin = typeof location.origin === 'string' ? location.origin : ''; + const isOpaqueFile = location.protocol === 'file:' + || rawOrigin === 'null' + || rawOrigin === 'file://' + || rawOrigin.startsWith('file:'); + return isOpaqueFile + ? { declaredOrigin: 'null', targetOrigin: '*' } + : { declaredOrigin: rawOrigin, targetOrigin: rawOrigin }; + }; const findFallbackSessionByWindow = (sourceWindow) => { if (!sourceWindow || !window.fallbackExamSessions || typeof fallbackExamSessions.entries !== 'function') { return null; @@ -766,30 +675,123 @@ function setupMessageListener() { if (!entry || !entry.rec || !entry.rec.win || entry.rec.win.closed) { return; } - const payload = entry.rec.initPayload || { + const messageOrigin = resolveFallbackMessageOrigin(); + const targetOrigin = messageOrigin.targetOrigin; + if (!targetOrigin) return; + if (!entry.rec.windowSessionToken) { + const cryptoApi = window.crypto; + if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return; + const bytes = new Uint8Array(24); + cryptoApi.getRandomValues(bytes); + entry.rec.windowSessionToken = Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + } + const payload = Object.assign({}, entry.rec.initPayload || { examId: entry.rec.examId, - parentOrigin: window.location.origin, + parentOrigin: messageOrigin.declaredOrigin, sessionId: entry.rec.sessionId || entry.sid - }; + }, { + parentOrigin: messageOrigin.declaredOrigin, + windowSessionToken: entry.rec.windowSessionToken + }); + entry.rec.initPayload = payload; try { - entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload }, '*'); - entry.rec.win.postMessage({ type: 'init_exam_session', data: payload }, '*'); + entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload, source: 'exam_host' }, targetOrigin); + entry.rec.win.postMessage({ type: 'init_exam_session', data: payload, source: 'exam_host' }, targetOrigin); } catch (_) { } }; - window.addEventListener('message', (event) => { - // 更兼容的安全检查:允许同源或file协议下的子窗口 + const sendFallbackSubmitOutcome = (rec, payload, succeeded, errorCode = '') => { + const submissionId = payload && payload.submissionId != null ? String(payload.submissionId).trim() : ''; + const sessionId = payload && payload.sessionId != null ? String(payload.sessionId).trim() : ''; + if (!rec || !rec.win || rec.win.closed || !submissionId || !sessionId) return false; + const targetOrigin = resolveFallbackMessageOrigin().targetOrigin; + if (!targetOrigin || !rec.windowSessionToken) return false; try { - if (event.origin && event.origin !== 'null' && event.origin !== window.location.origin) { - return; - } - } catch (_) { } + rec.win.postMessage({ + type: succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED', + data: { + examId: payload.examId || rec.examId || null, + sessionId, + suiteSessionId: payload.suiteSessionId || null, + submissionId, + errorCode: succeeded ? null : (errorCode || 'save_failed'), + windowSessionToken: rec.windowSessionToken + }, + source: 'exam_host', + timestamp: Date.now() + }, targetOrigin); + return true; + } catch (_) { + return false; + } + }; + + const sendFallbackVocabOutcome = (rec, payload, succeeded, errorCode = '') => { + const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : ''; + const sessionId = payload && payload.sessionId != null + ? String(payload.sessionId).trim() + : String(rec && rec.sessionId || ''); + if (!rec || !rec.win || rec.win.closed || !requestId || !sessionId || !rec.windowSessionToken) return false; + const targetOrigin = resolveFallbackMessageOrigin().targetOrigin; + if (!targetOrigin) return false; + try { + rec.win.postMessage({ + type: succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED', + data: { + requestId, + examId: payload.examId || rec.examId || null, + sessionId, + errorCode: succeeded ? null : (errorCode || 'save_failed'), + windowSessionToken: rec.windowSessionToken + }, + source: 'exam_host', + timestamp: Date.now() + }, targetOrigin); + return true; + } catch (_) { + return false; + } + }; + const verifyFallbackPracticeCompletionRecord = async (record) => { + if (!record || typeof record !== 'object' || !record.id || !record.examId || !record.sessionId) { + return null; + } + if (!window.AppData || !window.AppData.practice || typeof window.AppData.practice.get !== 'function') { + return null; + } + const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' }); + if (!persisted || typeof persisted !== 'object') { + return null; + } + return String(persisted.id || '') === String(record.id) + && String(persisted.examId || '') === String(record.examId) + && String(persisted.sessionId || '') === String(record.sessionId) + ? persisted + : null; + }; + + window.addEventListener('message', (event) => { const data = event.data || {}; const type = data.type; + const payload = data && typeof data.data === 'object' ? data.data : data; + const matched = findFallbackSessionByWindow(event.source); + if (!matched || !matched.rec) return; + const isLocalFile = window.location && window.location.protocol === 'file:'; + if (isLocalFile ? event.origin !== 'null' : event.origin !== window.location.origin) return; + const allowedSources = new Set(['practice_page', 'inline_collector', 'listening_record_bridge', 'suite_placeholder']); + if (!allowedSources.has(data.source || payload.source)) return; + const permitsPreInit = type === 'REQUEST_INIT' + || (type === 'SESSION_READY' && payload.initialized !== true); + if (!permitsPreInit && ( + !matched.rec.windowSessionToken + || payload.windowSessionToken !== matched.rec.windowSessionToken + )) { + return; + } if (type === 'SESSION_READY') { - const payload = data && typeof data.data === 'object' ? data.data : data; - const matched = findFallbackSessionByWindow(event.source); if (payload && payload.initialized === false) { sendFallbackInit(matched); return; @@ -803,71 +805,99 @@ function setupMessageListener() { } } catch (_) { } } else if (type === 'REQUEST_INIT') { - sendFallbackInit(findFallbackSessionByWindow(event.source)); + sendFallbackInit(matched); } else if (type === 'VOCAB_HIGHLIGHT_SAVE') { const payload = data.data && typeof data.data === 'object' ? data.data : data; - saveReadingHighlightVocab(payload).catch((error) => { + const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : ''; + if (!requestId) return; + saveReadingHighlightVocab(payload).then((saved) => { + sendFallbackVocabOutcome(matched.rec, payload, Boolean(saved), saved ? '' : 'save_failed'); + }).catch((error) => { console.warn('[VocabStore] 阅读高亮生词保存异常:', error); + sendFallbackVocabOutcome(matched.rec, payload, false, 'save_failed'); }); } else if (type === 'PRACTICE_COMPLETE' || type === 'practice_completed') { const payload = extractCompletionPayload(data) || {}; const sessionId = extractCompletionSessionId(data); - const matchedByWindow = findFallbackSessionByWindow(event.source); + const matchedByWindow = matched; const rec = sessionId ? (fallbackExamSessions.get(sessionId) || (matchedByWindow && matchedByWindow.rec)) : (matchedByWindow && matchedByWindow.rec); const recSessionId = rec && (rec.sessionId || (matchedByWindow && matchedByWindow.sid) || sessionId); if (recSessionId && payload && typeof payload === 'object') { payload.sessionId = recSessionId; } + if (!payload.submissionId || !recSessionId) return; + const receiptKey = payload.submissionId && recSessionId + ? `${recSessionId}:${String(payload.submissionId)}` + : ''; + if (rec && receiptKey && rec.practiceSubmitReceipt === receiptKey) { + sendFallbackSubmitOutcome(rec, payload, true); + return; + } const shouldNotify = shouldAnnounceCompletion(recSessionId || sessionId); - if (rec) { - console.log('[System] 收到练习完成,保存 canonical 记录'); - const cleanupAfterCompletion = () => { - try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { } - try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { } - }; - savePracticeCompletionRecord(rec.examId, payload).then( - () => { - // 保存成功:提示完成、展示摘要、同步记录。 - cleanupAfterCompletion(); - if (shouldNotify) { - showMessage('练习已完成,正在更新记录...', 'success'); - showCompletionSummary(payload); - } - setTimeout(syncPracticeRecords, 300); - }, - (saveError) => { - // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要, - // 避免在记录未落库时误导用户;同步一次以反映真实状态。 - console.error('[System] 练习完成记录保存失败:', saveError); - cleanupAfterCompletion(); - if (shouldNotify) { - showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error'); - } - setTimeout(syncPracticeRecords, 300); + const cleanupAfterCompletion = () => { + try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { } + if (rec && receiptKey) { + try { if (rec.submitCleanupTimer) clearTimeout(rec.submitCleanupTimer); } catch (_) { } + rec.submitCleanupTimer = setTimeout(() => { + try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { } + }, 120000); + if (rec.submitCleanupTimer && typeof rec.submitCleanupTimer.unref === 'function') { + rec.submitCleanupTimer.unref(); } - ); - } else { - console.log('[System] 收到练习完成消息,正在同步记录...'); + return; + } + try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { } + }; + const onCompletionSaved = async (savedRecord) => { + const persistedRecord = await verifyFallbackPracticeCompletionRecord(savedRecord); + if (!persistedRecord) { + throw new Error('canonical_completion_readback_failed'); + } + if (rec && receiptKey) rec.practiceSubmitReceipt = receiptKey; + sendFallbackSubmitOutcome(rec, payload, true); + // 保存成功:提示完成、展示摘要、同步记录。 + cleanupAfterCompletion(); if (shouldNotify) { showMessage('练习已完成,正在更新记录...', 'success'); showCompletionSummary(payload); } - setTimeout(syncPracticeRecords, 300); + setTimeout(() => ensurePracticeRecordsSync('completion-saved'), 300); + }; + const onCompletionSaveFailed = (saveError) => { + sendFallbackSubmitOutcome(rec, payload, false, 'save_failed'); + // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要, + // 避免在记录未落库时误导用户;同步一次以反映真实状态。 + console.error('[System] 练习完成记录保存失败:', saveError); + cleanupAfterCompletion(); + if (shouldNotify) { + showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error'); + } + setTimeout(() => ensurePracticeRecordsSync('completion-save-failed'), 300); + }; + if (rec) { + console.log('[System] 收到练习完成,保存 canonical 记录'); + savePracticeCompletionRecord(rec.examId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed); + } else { + // 会话映射缺失(例如主页刷新后 fallbackExamSessions 已被清空)。此前这里只做只读同步, + // 记录一个字都不写却提示“练习已完成”。改为用消息自带的 examId 走同一条持久化路径。 + const payloadExamId = resolveCompletionExamId(data, payload); + if (payloadExamId) { + console.log('[System] 会话映射缺失,改用消息自带 examId 保存 canonical 记录:', payloadExamId); + savePracticeCompletionRecord(payloadExamId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed); + } else { + // 连 examId 都没有就无法归属到任何题目,必须明确报错,绝不能报成功。 + console.error('[System] 练习完成消息缺少 examId,无法保存记录'); + sendFallbackSubmitOutcome(rec, payload, false, 'missing_exam_id'); + if (shouldNotify) { + showMessage('练习已完成,但记录保存失败:缺少题目标识,无法归档本次练习。', 'error'); + } + setTimeout(() => ensurePracticeRecordsSync('completion-missing-exam-id'), 300); + } } } }); } -function setupStorageSyncListener() { - window.addEventListener('storage-sync', (event) => { - console.log('[System] 收到存储同步事件,正在更新练习记录...', event.detail); - //可以选择性地只更新受影响的key,但为了简单起见,我们直接同步所有记录 - // if (event.detail && event.detail.key === 'practice_records') { - syncPracticeRecords(); - // } - }); -} - function normalizeFallbackAnswerValue(value) { if (value === null || value === undefined) { return ''; @@ -1047,9 +1077,9 @@ async function saveFallbackSpellingErrors(examId, realData, exam = {}) { } } -function findExamForCompletion(examId, realData = {}) { - const list = typeof getExamIndexState === 'function' ? getExamIndexState() : []; - let exam = Array.isArray(list) ? (list.find(e => e.id === examId) || {}) : {}; +function findExamForCompletion(examId, realData = {}, examIndex = []) { + const list = Array.isArray(examIndex) ? examIndex : []; + let exam = list.find(e => e.id === examId) || {}; if (exam.id || !realData) { return exam; @@ -1137,31 +1167,45 @@ async function savePracticeCompletionRecord(examId, realData) { return null; } - const api = window.PracticeRecordAPI; - if (!api || typeof api.saveCompletion !== 'function') { - throw new Error('统一练习记录 API 未就绪'); - } - const exam = findExamForCompletion(examId, realData); + const examIndex = await resolveActiveExamIndex(); + const exam = findExamForCompletion(examId, realData, examIndex); const category = resolveCompletionCategory(exam, realData); - const record = await api.saveCompletion(realData, { - examId, - examEntry: exam, - metadata: { + // 启动时捕获的题库配置 ID:优先取 PRACTICE_COMPLETE 消息或 realData 已显式透传的值, + // 否则显式写入 null(保留 key),让 AppData provenance 不再回退到当前激活题库, + // 避免用户在考试过程中切换题库导致记录来源不一致。 + const launchLibraryConfigurationId = (realData && realData.libraryConfigurationId != null + && realData.libraryConfigurationId !== '') + ? realData.libraryConfigurationId + : (realData && realData.metadata && realData.metadata.libraryConfigurationId != null + && realData.metadata.libraryConfigurationId !== '') + ? realData.metadata.libraryConfigurationId + : null; + const receipt = await window.AppData.practice.completeAttempt({ + record: Object.assign({}, realData, { + examId, + title: realData.title || exam.title || '', + category, + frequency: exam.frequency || realData.frequency || 'unknown', + type: exam.type || realData.type || null, + metadata: Object.assign({}, realData.metadata || {}, { examId, examTitle: exam.title || realData.title || '', category, frequency: exam.frequency || realData.frequency || 'unknown', - type: exam.type || realData.type || null - } - }, exam, { - currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '0.6.2-fix', - maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000, - updateStats: true + type: exam.type || realData.type || null, + libraryConfigurationId: launchLibraryConfigurationId + }) + }), + operationId: realData.operationId + || realData.messageId + || (realData.submissionId + ? `practice-complete:${examId}:${realData.sessionId || 'session'}:${realData.submissionId}` + : undefined) }); await saveFallbackSpellingErrors(examId, realData, exam); console.log('[PracticeRecord] 练习完成数据已保存到 canonical store'); - return record; + return receipt.record; } catch (e) { console.error('[PracticeRecord] 保存练习记录失败:', e); throw e; @@ -1230,14 +1274,14 @@ function getOverviewView() { return overviewViewInstance; } -function updateOverview() { +function updateOverview(examIndex = []) { const categoryContainer = document.getElementById('category-overview'); if (!categoryContainer) { console.warn('[Overview] 找不到 category-overview 容器'); return; } - const currentExamIndex = getExamIndexState(); + const currentExamIndex = Array.isArray(examIndex) ? examIndex : []; const statsService = window.AppServices && window.AppServices.overviewStats; const stats = statsService ? statsService.calculate(currentExamIndex) : @@ -1663,10 +1707,35 @@ function recordMatchesExamType(record, targetType, examIndex) { return true; } +// 练习记录渲染前的来源过滤。判定本身不在这里实现,而是复用 +// js/data/practiceRecordSource.js(与 practice.stats / achievements.progress 投影器同源), +// 因为“列表看不见但计入统计”的 bug 正是由两处各写一套判定造成的。 +// +// 用 filterRecordsForHistoryView 而不是 filterRealPracticeRecords:两者对"真实记录"的 +// 判定完全相同,前者额外放行新手引导显式登记的演示记录 id(引导需要用户看见那一行)。 +// 该例外只存在于视图层,投影器读不到,因此统计与成就仍严格排除演示数据。 +function filterRealPracticeRecordsForView(records) { + const list = Array.isArray(records) ? records : []; + const classifier = window.PracticeRecordSource; + if (!classifier || typeof classifier.filterRecordsForHistoryView !== 'function') { + // core-foundation 里的 appData.js 缺少该模块会直接抛错、应用根本起不来, + // 所以走到这里只能是加载顺序被破坏。此时绝不本地复刻判定:显式报错并保留全部记录, + // 宁可多显示演示记录,也不能重演"真实记录被吃掉、练习记录页整页空白"。 + console.error('[PracticeHistory] PracticeRecordSource 未加载,已跳过演示记录过滤(判定必须与统计/成就同源)'); + return list; + } + return classifier.filterRecordsForHistoryView(list); +} + // Phase 3: 练习记录视图更新 - 保留在 main.js(依赖多个组件,暂不迁移) -function updatePracticeView() { - const rawRecords = getPracticeRecordsState(); - const records = rawRecords.filter((record) => record && (record.dataSource === 'real' || record.dataSource === undefined)); +function updatePracticeView(recordsSnapshot = [], examIndexSnapshot = []) { + const rawRecords = Array.isArray(recordsSnapshot) ? recordsSnapshot : []; + const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : []; + // 排除演示/种子记录。判定必须与 practice.stats / achievements.progress 两个投影器 + // 完全一致,否则会重演“演示记录在列表里看不见,却计入成绩统计和成就解锁”。 + // 唯一权威定义在 js/data/practiceRecordSource.js(含“dataSource 缺失即真实记录”, + // 该语义曾因被收窄导致练习记录页整页空白,不得回退)。 + const records = filterRealPracticeRecordsForView(rawRecords); const stats = window.PracticeStats; const summary = stats && typeof stats.calculateSummary === 'function' @@ -1695,10 +1764,9 @@ function updatePracticeView() { const examType = getCurrentExamType(); if (examType !== 'all') { if (stats && typeof stats.filterByExamType === 'function') { - recordsToShow = stats.filterByExamType(recordsToShow, getExamIndexState(), examType); + recordsToShow = stats.filterByExamType(recordsToShow, examIndex, examType); } else { - const examIndexSnapshot = getExamIndexState(); - recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndexSnapshot)); + recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndex)); } } @@ -1730,7 +1798,7 @@ function updatePracticeView() { const priorityRenderer = ensurePracticePriorityRenderer(); if (priorityRenderer && typeof priorityRenderer.update === 'function') { - priorityRenderer.update(recordsForInsights, getExamIndexState(), { examType }); + priorityRenderer.update(recordsForInsights, examIndex, { examType }); } // --- 4. Render history list --- @@ -1762,7 +1830,7 @@ function searchPracticeHistory(query) { if (clearButton) { clearButton.hidden = window.__practiceHistoryQuery.length === 0; } - updatePracticeView(); + startPracticeRecordsSyncInBackground('history-search', { forceRender: true }); } function clearPracticeHistorySearch() { @@ -1776,20 +1844,20 @@ function clearPracticeHistorySearch() { searchPracticeHistory(''); } -function refreshBrowseProgressFromRecords(recordsOverride = null) { +function refreshBrowseProgressFromRecords(records, examIndex) { try { - const records = Array.isArray(recordsOverride) - ? recordsOverride - : (typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(window.practiceRecords) ? window.practiceRecords : [])); + const recordSnapshot = Array.isArray(records) ? records : []; + const indexSnapshot = Array.isArray(examIndex) ? examIndex : []; if (typeof updateBrowseAnchorsFromRecords === 'function') { - updateBrowseAnchorsFromRecords(records); + updateBrowseAnchorsFromRecords(recordSnapshot, indexSnapshot); + } + if (typeof rebuildBrowseCompletionIndex === 'function') { + rebuildBrowseCompletionIndex(recordSnapshot); } const browseView = document.getElementById('browse-view'); const isBrowseActive = browseView && browseView.classList.contains('active'); if (isBrowseActive && typeof loadExamList === 'function') { - loadExamList(); + loadExamList(indexSnapshot); } } catch (error) { console.warn('[Browse] 刷新浏览进度失败:', error); @@ -1802,28 +1870,11 @@ function ensurePracticeSessionSyncListener() { return; } practiceSessionEventBound = true; - document.addEventListener('practiceSessionCompleted', (event) => { - try { - const detail = event && event.detail ? event.detail : {}; - let record = detail.practiceRecord; - if (record && typeof record === 'object') { - record = enrichPracticeRecordForUI(record); - const current = getPracticeRecordsState(); - const filtered = Array.isArray(current) - ? current.filter((item) => item && item.id !== record.id) - : []; - setPracticeRecordsState([record, ...filtered]); - updatePracticeView(); - refreshBrowseProgressFromRecords([record, ...filtered]); - } - } catch (syncError) { - console.warn('[PracticeView] practiceSessionCompleted 事件处理失败:', syncError); - } finally { - // 仍然执行一次全面同步,确保 ScoreStorage/StorageRepo 状态一致 - setTimeout(() => { - try { syncPracticeRecords(); } catch (_) { } - }, 200); - } + document.addEventListener('practiceSessionCompleted', () => { + startPracticeRecordsSyncInBackground('session-completed', { + mode: 'summary', + forceRender: true + }); }); } @@ -1940,10 +1991,6 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu try { window.app.browseCategory(category, type, filterMode, path); console.log('[browseCategory] Called app.browseCategory with filterMode:', filterMode); - // 常规模式仍需刷新题库;频率模式由 browseController 接管 - if (!filterMode) { - setTimeout(() => loadExamList(), 100); - } return; } catch (error) { console.warn('[browseCategory] window.app.browseCategory 调用失败,使用降级路径:', error); @@ -1981,12 +2028,16 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu } } -function filterByType(type) { +async function filterByType(type, examIndexOverride = null) { const requestedType = type; + let examIndex = Array.isArray(examIndexOverride) ? examIndexOverride : []; try { + if (!Array.isArray(examIndexOverride)) { + examIndex = await resolveActiveExamIndex(); + } const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function' - ? window.hasActiveListeningLibrary() - : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening')); + ? window.hasActiveListeningLibrary(examIndex) + : examIndex.some((exam) => exam && exam.type === 'listening'); if (requestedType === 'listening' && !listeningAvailable) { type = 'all'; if (typeof window.showMessage === 'function') { @@ -2013,7 +2064,7 @@ function filterByType(type) { if (window.browseController && window.browseController.currentMode !== 'default' && typeof window.browseController.resetToDefault === 'function') { - window.browseController.resetToDefault(); + window.browseController.resetToDefault(examIndex); } // 更新题库浏览筛选按钮的 active 状态 @@ -2038,12 +2089,13 @@ function filterByType(type) { } // 刷新题库列表 - loadExamList(); + await loadExamList(examIndex); } // 应用分类筛选(供 App/总览调用) -function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) { +async function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) { try { + const indexSnapshot = await resolveActiveExamIndex(); const memorizeSelectionActive = isReadingMemorizeBrowseMode(); if (memorizeSelectionActive) { category = 'all'; @@ -2065,7 +2117,6 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat // 若未显式给出类型,则根据当前题库推断(同时存在时不限定类型) if (!type || type === 'all') { try { - const indexSnapshot = getExamIndexState(); const hasReading = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'reading'); const hasListening = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'listening'); if (hasReading && !hasListening) type = 'reading'; @@ -2077,8 +2128,8 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat const normalizedType = normalizeExamType(type); const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null; const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function' - ? window.hasActiveListeningLibrary() - : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening')); + ? window.hasActiveListeningLibrary(indexSnapshot) + : indexSnapshot.some((exam) => exam && exam.type === 'listening'); const effectiveFilterMode = listeningAvailable ? filterMode : null; const effectiveType = (!listeningAvailable && normalizedType === 'listening') ? 'all' : normalizedType; @@ -2091,9 +2142,9 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat if (window.browseController) { try { if (!window.browseController.buttonContainer) { - window.browseController.initialize('type-filter-buttons'); + window.browseController.initialize('type-filter-buttons', indexSnapshot); } - window.browseController.setMode(effectiveFilterMode); + window.browseController.setMode(effectiveFilterMode, indexSnapshot); } catch (error) { console.warn('[Browse] 切换浏览模式失败:', error); } @@ -2105,7 +2156,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat if (window.browseController && window.browseController.currentMode !== 'default' && typeof window.browseController.resetToDefault === 'function') { - window.browseController.resetToDefault(); + window.browseController.resetToDefault(indexSnapshot); } } @@ -2119,7 +2170,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat // 如果是频率模式,setMode 已经处理了刷新,不需要再次调用 loadExamList // 只有在默认模式下才显式调用 if (!effectiveFilterMode) { - loadExamList(); + await loadExamList(indexSnapshot); } // 若未在浏览视图,则尽力切换 @@ -2140,16 +2191,21 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat } // Initialize browse view when it's activated -function initializeBrowseView() { +async function initializeBrowseView(options = {}) { console.log('[System] Initializing browse view...'); - startPracticeRecordsSyncInBackground('browse-view'); + const [examIndex] = await Promise.all([ + resolveActiveExamIndex(), + typeof window.whenBrowseViewPreferencesReady === 'function' + ? window.whenBrowseViewPreferencesReady() + : Promise.resolve() + ]); // 初始化 browseController if (window.browseController && !window.browseController.buttonContainer) { - window.browseController.initialize('type-filter-buttons'); + window.browseController.initialize('type-filter-buttons', examIndex); } if (typeof window.refreshListeningAvailabilityUI === 'function') { - window.refreshListeningAvailabilityUI(); + window.refreshListeningAvailabilityUI(examIndex); } const persisted = getPersistedBrowseFilter(); @@ -2161,12 +2217,11 @@ function initializeBrowseView() { setBrowseTitle(formatBrowseTitle('all', 'all')); } - ensurePracticeRecordsSync('browse-view').then(() => { - refreshBrowseProgressFromRecords(); - }); setupBrowseSortControl(); setupBrowseFrequencyFilterControl(); - loadExamList(); + if (!options.skipLoad) { + await loadExamList(examIndex); + } } function normalizeBrowseFrequencyFilter(value) { @@ -2188,11 +2243,27 @@ function refreshBrowseResults() { loadExamList(); } -function setupBrowseControls() { +let browseControlsSeeded = false; +async function setupBrowseControls() { + if (!browseControlsSeeded) { + try { + const browse = await window.AppData.preferences.getBrowse(); + if (browse) { + window.__browseSortMode = browse.sortMode || window.__browseSortMode; + window.__browseFrequencyFilter = browse.frequencyFilter || window.__browseFrequencyFilter; + } + } catch (_) { /* defaults remain active */ } + browseControlsSeeded = true; + } setupBrowseSortControl(); setupBrowseFrequencyFilterControl(); } +async function persistBrowsePreference(patch) { + const current = await window.AppData.preferences.getBrowse() || {}; + await window.AppData.preferences.setBrowse(Object.assign({}, current, patch)); +} + function setupBrowseSortControl() { const sortSelect = document.getElementById('browse-sort-select'); if (!sortSelect || sortSelect.dataset.bound === 'true') { @@ -2203,22 +2274,12 @@ function setupBrowseSortControl() { return mode === 'frequency-desc' || mode === 'difficulty-desc' ? mode : 'default'; }; let savedMode = String(window.__browseSortMode || '').trim().toLowerCase(); - if (!savedMode) { - try { - savedMode = String(window.localStorage.getItem('browse_sort_mode') || 'default').trim().toLowerCase(); - } catch (_) { - savedMode = 'default'; - } - } + if (!savedMode) savedMode = 'default'; sortSelect.value = normalizeSortMode(savedMode); window.__browseSortMode = sortSelect.value; sortSelect.addEventListener('change', () => { window.__browseSortMode = normalizeSortMode(sortSelect.value); - try { - window.localStorage.setItem('browse_sort_mode', window.__browseSortMode); - } catch (_) { - // ignore storage failures - } + persistBrowsePreference({ sortMode: window.__browseSortMode }).catch(console.warn); refreshBrowseResults(); }); sortSelect.dataset.bound = 'true'; @@ -2243,13 +2304,6 @@ function setupBrowseFrequencyFilterControl() { return; } let savedFilter = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all'); - if (savedFilter === 'all') { - try { - savedFilter = normalizeBrowseFrequencyFilter(window.localStorage.getItem('browse_frequency_filter') || 'all'); - } catch (_) { - savedFilter = 'all'; - } - } window.__browseFrequencyFilter = savedFilter; updateBrowseFrequencyButtons(savedFilter); } @@ -2259,11 +2313,7 @@ function filterByFrequency(filter) { const current = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all'); const next = requested !== 'all' && requested === current ? 'all' : requested; window.__browseFrequencyFilter = next; - try { - window.localStorage.setItem('browse_frequency_filter', next); - } catch (_) { - // ignore storage failures - } + persistBrowsePreference({ frequencyFilter: next }).catch(console.warn); updateBrowseFrequencyButtons(next); refreshBrowseResults(); } @@ -2308,33 +2358,36 @@ function filterRecordsByType(type) { setTimeout(window.updateSegmentedIndicators, 10); } - updatePracticeView(); + startPracticeRecordsSyncInBackground('record-type-filter', { forceRender: true }); } -function loadExamList() { - setupBrowseControls(); +async function loadExamList(examIndexOverride = null) { + await setupBrowseControls(); + const examIndex = Array.isArray(examIndexOverride) + ? examIndexOverride + : await resolveActiveExamIndex(); if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') { - return window.ExamActions.loadExamList(); + return window.ExamActions.loadExamList(examIndex); } console.warn('[main.js] ExamActions.loadExamList 未就绪,尝试加载 browse-view 组'); if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { window.AppLazyLoader.ensureGroup('browse-view').then(function () { setupBrowseControls(); if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') { - window.ExamActions.loadExamList(); + window.ExamActions.loadExamList(examIndex); } else { // 最终降级:直接 DOM 渲染 - loadExamListFallback(); + loadExamListFallback(examIndex); } }).catch(function (err) { console.error('[main.js] browse-view 组加载失败:', err); - loadExamListFallback(); + loadExamListFallback(examIndex); }); } else { // 无懒加载器,直接降级 - loadExamListFallback(); + loadExamListFallback(examIndex); } } @@ -2383,13 +2436,11 @@ function clearReadingMemorizeBrowseMode() { } } -function selectReadingMemorizeExam(examId) { +async function selectReadingMemorizeExam(examId) { if (window.ExamActions && typeof window.ExamActions.launchReadingMemorizeExam === 'function') { return window.ExamActions.launchReadingMemorizeExam(examId); } - const list = typeof getExamIndexState === 'function' - ? getExamIndexState() - : (Array.isArray(window.examIndex) ? window.examIndex : []); + const list = await resolveActiveExamIndex(); const exam = Array.isArray(list) ? list.find(function (item) { return item && String(item.id) === String(examId); }) : null; @@ -2486,10 +2537,10 @@ function createFallbackExamCard(exam, options = {}) { return item; } -function loadExamListFallback() { +function loadExamListFallback(examIndexSnapshot = []) { console.warn('[main.js] 使用降级渲染逻辑'); try { - let examIndex = typeof getExamIndexState === 'function' ? getExamIndexState() : (Array.isArray(window.examIndex) ? window.examIndex : []); + let examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : []; const container = document.getElementById('exam-list-container'); if (!container) return; @@ -2590,83 +2641,11 @@ function loadExamListFallback() { } } -function resetBrowseViewToAll() { - if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') { - return window.ExamActions.resetBrowseViewToAll(); - } - console.warn('[main.js] ExamActions.resetBrowseViewToAll 未就绪'); - - // 清除频率模式状态,确保回到默认列表 - clearReadingMemorizeBrowseMode(); - window.__browseFilterMode = 'default'; - window.__browsePath = null; - - if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { - window.AppLazyLoader.ensureGroup('browse-view').then(function () { - if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') { - window.ExamActions.resetBrowseViewToAll(); - } else { - // 降级:重置状态并重新加载 - if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all'); - loadExamList(); - } - }).catch(function () { - if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all'); - loadExamList(); - }); - } else { - if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all'); - loadExamList(); - } -} - -function displayExams(exams) { - if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') { - return window.ExamActions.displayExams(exams); - } - console.warn('[main.js] ExamActions.displayExams 未就绪,使用降级渲染'); - - // 立即降级渲染(displayExams 需要同步执行) - try { - const container = document.getElementById('exam-list-container'); - if (!container) return; - - // 清除 loading 指示器(修复 P2 bug) - const loadingEl = document.querySelector('#browse-view .loading'); - if (loadingEl) { - loadingEl.style.display = 'none'; - } - - const memorizeSelectionActive = isReadingMemorizeBrowseMode(); - if (typeof window.syncReadingMemorizeBrowseModeUI === 'function') { - window.syncReadingMemorizeBrowseModeUI(); - } - const normalizedExams = memorizeSelectionActive - ? filterReadingMemorizeExamsFallback(exams) - : (Array.isArray(exams) ? exams : []); - if (memorizeSelectionActive && typeof setBrowseTitle === 'function') { - setBrowseTitle('阅读背题选题'); - } - if (normalizedExams.length === 0) { - container.innerHTML = '

未找到匹配的题目

'; - return; - } - - const list = document.createElement('div'); - list.className = 'exam-list'; - normalizedExams.forEach(function (exam) { - if (!exam) return; - list.appendChild(createFallbackExamCard(exam, { - selectionMode: memorizeSelectionActive ? 'reading-memorize' : '', - showMeta: true - })); - }); - container.innerHTML = ''; - container.appendChild(list); - } catch (err) { - console.error('[main.js] displayExams 降级渲染失败:', err); - } -} +// resetBrowseViewToAll / displayExams 的唯一实现在 js/app/examActions.js, +// 由其 IIFE 导出到 window.ExamActions 与 window 上。此处不再重复定义: +// 两个文件同处 browse.bundle.js,重名的顶层声明会与 examActions 的全局写入 +// 静默互相覆盖(历史上 loadExamList 就因此渲染空白)。 +// 调用方请走 window.ExamActions.*(未加载时有 main-entry.js 的懒加载代理兜底)。 function getResourceCore() { return window.ResourceCore || null; @@ -2742,9 +2721,8 @@ function openExam(examId, options = {}) { return showMessage('统一练习入口未就绪:app.openExam 不可用,已阻止打开原始题源 HTML。', 'error'); } -function viewPDF(examId) { - // 增加数组化防御 - const list = getExamIndexState(); +async function viewPDF(examId) { + const list = await resolveActiveExamIndex(); const exam = list.find(e => e.id === examId); if (!exam || !exam.pdfFilename) return showMessage('未找到PDF文件', 'error'); @@ -2814,8 +2792,8 @@ function getViewName(viewName) { } } -function updateSystemInfo() { - const examIndexSnapshot = getExamIndexState(); +function updateSystemInfo(examIndex = []) { + const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : []; if (!examIndexSnapshot || examIndexSnapshot.length === 0) return; const readingExams = examIndexSnapshot.filter(e => e.type === 'reading'); const listeningExams = examIndexSnapshot.filter(e => e.type === 'listening'); @@ -2944,14 +2922,14 @@ async function getActiveLibraryConfigurationKey() { if (manager && typeof manager.getActiveLibraryConfigurationKey === 'function') { return await manager.getActiveLibraryConfigurationKey(); } - return await storage.get('active_exam_index_key', 'exam_index'); + return window.AppData.library.getActive(); } async function getLibraryConfigurations() { const manager = await ensureLibraryManagerReady(); if (manager && typeof manager.getLibraryConfigurations === 'function') { return await manager.getLibraryConfigurations(); } - return await storage.get('exam_index_configurations', []); + return await window.AppData.library.listConfigurations(); } async function saveLibraryConfiguration(name, key, examCount) { const manager = await ensureLibraryManagerReady(); @@ -2973,11 +2951,16 @@ function handleFolderSelection(event) { /* legacy stub - replaced by modal-speci // --- Functions Restored from Backup --- +let debouncedExamSearch = null; + function searchExams(query) { toggleSearchClearButton(query); if (window.performanceOptimizer && typeof window.performanceOptimizer.debounce === 'function') { - const debouncedSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search'); - debouncedSearch(query); + // 跨 input 事件复用同一个 debounce 闭包,避免每个字符都排队一次搜索。 + if (!debouncedExamSearch) { + debouncedExamSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search'); + } + debouncedExamSearch(query); } else { // Fallback: direct call if optimizer not available performSearch(query); @@ -3008,8 +2991,8 @@ function clearSearch() { searchExams(''); } -function getBrowseFilteredExamBase() { - const examIndex = getExamIndexState(); +function getBrowseFilteredExamBase(examIndexSnapshot = []) { + const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : []; const activeCategory = typeof getCurrentCategory === 'function' ? getCurrentCategory() : 'all'; const activeExamType = typeof getCurrentExamType === 'function' ? getCurrentExamType() : 'all'; const isFrequencyMode = window.__browseFilterMode && window.__browseFilterMode !== 'default'; @@ -3045,7 +3028,7 @@ function getBrowseFilteredExamBase() { return list; } -function performSearch(query) { +async function performSearch(query) { const normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { loadExamList(); @@ -3054,7 +3037,7 @@ function performSearch(query) { // 调试日志 console.log('[Search] 执行搜索,查询词:', normalizedQuery); - const searchBase = getBrowseFilteredExamBase(); + const searchBase = getBrowseFilteredExamBase(await resolveActiveExamIndex()); console.log('[Search] 当前筛选后索引数量:', searchBase.length); const searchResults = searchBase.filter(exam => { if (exam.searchText) { @@ -3066,7 +3049,11 @@ function performSearch(query) { }); console.log('[Search] 搜索结果数量:', searchResults.length); - displayExams(searchResults); + if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') { + window.ExamActions.displayExams(searchResults); + } else if (typeof window.displayExams === 'function') { + window.displayExams(searchResults); + } } async function toggleBulkDelete() { @@ -3078,7 +3065,7 @@ async function toggleBulkDelete() { if (typeof showMessage === 'function') { showMessage('批量管理模式已开启,点击记录进行选择', 'info'); } - updatePracticeView(); + await syncPracticeRecords({ forceRender: true }); return; } @@ -3098,7 +3085,7 @@ async function toggleBulkDelete() { clearSelectedRecordsState(); refreshBulkDeleteButton(); - updatePracticeView(); + await syncPracticeRecords({ forceRender: true }); } async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) { @@ -3110,21 +3097,21 @@ async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) { const records = await listCanonicalPracticeRecords(); const baseList = Array.isArray(records) ? records : []; - const recordsToKeep = baseList.filter(record => !normalizedIds.includes(normalizeRecordId(record && record.id))); - - const deletedCount = baseList.length - recordsToKeep.length; + const recordIds = new Set(baseList.map((record) => normalizeRecordId(record && record.id)).filter(Boolean)); + const deletedCount = normalizedIds.filter((id) => recordIds.has(id)).length; if (deletedCount === 0) { showMessage('未找到可删除的记录', 'warning'); return; } - await persistPracticeRecordsAndRefresh(recordsToKeep, 'bulk-delete'); + await window.AppData.practice.deleteMany({ recordIds: normalizedIds }); + await syncPracticeRecords({ forceRender: true, trigger: 'bulk-delete' }); showMessage(`已删除 ${deletedCount} 条记录`, 'success'); console.log(`[System] 批量删除了 ${deletedCount} 条练习记录`); } -function toggleRecordSelection(recordId) { +async function toggleRecordSelection(recordId) { if (!getBulkDeleteModeState()) return; const normalizedId = normalizeRecordId(recordId); @@ -3138,7 +3125,7 @@ function toggleRecordSelection(recordId) { } else { addSelectedRecordState(normalizedId); } - updatePracticeView(); // Re-render to show selection state + await syncPracticeRecords({ forceRender: true }); } @@ -3160,15 +3147,19 @@ async function deleteRecord(recordId) { const confirmMessage = `确定要删除这条练习记录吗?\n\n题目: ${record.title}\n时间: ${new Date(record.date).toLocaleString()}\n\n此操作不可恢复。`; if (confirm(confirmMessage)) { - const nextRecords = records.filter((record) => String(record.id) !== String(recordId)); - await persistPracticeRecordsAndRefresh(nextRecords, 'single-delete'); + await window.AppData.practice.delete({ recordId }); + await syncPracticeRecords({ forceRender: true, trigger: 'single-delete' }); showMessage('记录已删除', 'success'); } } async function clearPracticeData() { if (confirm('确定要清除所有练习记录吗?此操作不可恢复。')) { - await persistPracticeRecordsAndRefresh([], 'clear-all'); + await window.AppData.practice.clear(); + await syncPracticeRecords({ forceRender: true, trigger: 'clear-all' }); + if (window.AppData && window.AppData.recovery && typeof window.AppData.recovery.clear === 'function') { + await window.AppData.recovery.clear(); + } processedSessions.clear(); clearSelectedRecordsState(); setBulkDeleteModeState(false); @@ -3178,44 +3169,11 @@ async function clearPracticeData() { } async function clearCache() { - const confirmMessage = '确定要清除所有缓存数据并清空练习记录吗?'; - if (!confirm(confirmMessage)) { - return; - } - - const localLegacyKeys = [ - 'exam_system_practice_records', - 'upgrade_v1_1_0_cleanup_done', - 'browse_state', - 'hasSeenGplLicense', - 'theme', - 'bloom-theme-mode', - 'blue-theme-mode' - ]; - - try { - if (window.storage && typeof storage.clear === 'function') { - await storage.clear(); - } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.clear === 'function') { - await window.PracticeRecordAPI.clear({ updateStats: true }); - } else { - throw new Error('统一练习记录 API 未就绪'); - } - } catch (error) { - console.warn('[clearCache] failed to clear managed storage:', error); - } - - localLegacyKeys.forEach((key) => { - try { localStorage.removeItem(key); } catch (_) { } - }); - setPracticeRecordsState([]); - processedSessions.clear(); - if (window.performanceOptimizer && typeof window.performanceOptimizer.cleanup === 'function') { - window.performanceOptimizer.cleanup(); + if (!window.SiteDataReset || typeof window.SiteDataReset.request !== 'function') { + showMessage('清除失败:全量重置服务未就绪', 'error'); + return false; } - - showMessage('缓存与练习记录已清除', 'success'); - setTimeout(() => { location.reload(); }, 1000); + return window.SiteDataReset.request(); } let libraryConfigViewInstance = null; @@ -3262,7 +3220,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { } seenKeys.add(key); normalized.push({ - name: key === 'exam_index' ? '默认题库' : key, + name: key, key, examCount: 0, timestamp: now @@ -3291,15 +3249,6 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { } } - if (!key && typeof record.name === 'string') { - const nameKey = normalizeKey(record.name); - if (/^exam_index(_\d+)?$/.test(nameKey)) { - key = nameKey; - record.key = key; - mutated = true; - } - } - if (!key) { mutated = true; continue; @@ -3334,7 +3283,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { seenKeys.add(key); if (typeof record.name !== 'string' || !record.name.trim()) { - record.name = key === 'exam_index' ? '默认题库' : key; + record.name = key; mutated = true; } else { record.name = record.name.trim(); @@ -3370,6 +3319,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { async function resolveLibraryConfigurations() { const rawConfigs = await getLibraryConfigurations(); + const activeIndex = await resolveActiveExamIndex(); let configs = Array.isArray(rawConfigs) ? rawConfigs : []; let mutated = false; @@ -3377,28 +3327,22 @@ async function resolveLibraryConfigurations() { configs = normalizedResult.normalized; mutated = normalizedResult.mutated; - if (configs.length === 0) { - try { - const count = getExamIndexState().length; - configs = [{ - name: '默认题库', - key: 'exam_index', - examCount: count, - timestamp: Date.now() - }]; - mutated = true; - const activeKey = await storage.get('active_exam_index_key'); - if (!activeKey) { - await storage.set('active_exam_index_key', 'exam_index'); - } - } catch (error) { - console.warn('[LibraryConfig] 无法初始化默认题库配置', error); - } + if (!configs.some(config => config && config.builtIn === true)) { + configs.unshift({ + name: '默认题库', + key: '', + id: null, + builtIn: true, + sourceType: 'built-in-manifest', + examCount: activeIndex.length + }); } if (mutated) { try { - await storage.set('exam_index_configurations', configs); + for (const config of configs) { + if (config && config.key && config.builtIn !== true) await window.AppData.library.updateConfiguration(config); + } } catch (error) { console.warn('[LibraryConfig] 无法同步题库配置记录', error); } @@ -3454,15 +3398,14 @@ async function deleteLibraryConfiguration(key) { async function debugCompareActiveIndexWithDefault() { try { const activeKey = await getActiveLibraryConfigurationKey(); - const activeIndex = Array.isArray(getExamIndexState()) ? getExamIndexState() : []; + const activeIndex = await resolveActiveExamIndex(); const defaultIndex = typeof window.getReadingExamIndex === 'function' ? window.getReadingExamIndex().map((exam) => Object.assign({}, exam, { type: 'reading' })) : (Array.isArray(window.__READING_EXAM_INDEX__) ? window.__READING_EXAM_INDEX__.map((exam) => Object.assign({}, exam, { type: 'reading' })) : []); const defaultListening = Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : []; - const storedDefault = await storage.get('exam_index', []); - const combinedDefault = storedDefault.length ? storedDefault : [...defaultIndex, ...defaultListening]; + const combinedDefault = [...defaultIndex, ...defaultListening]; const normalizeTail = (path) => { const p = String(path || '').replace(/\\/g, '/').split('/').filter(Boolean); @@ -3558,8 +3501,8 @@ function renderLibraryConfigFallback(container, configs, options) { if (!config) { return; } - const isActive = activeKey === config.key; - const isDefault = config.key === 'exam_index'; + const isDefault = config.builtIn === true; + const isActive = isDefault ? activeKey == null : activeKey === config.key; const item = document.createElement('div'); item.className = 'library-config-panel__item' + (activeKey === config.key ? ' library-config-panel__item--active' : ''); @@ -3583,7 +3526,7 @@ function renderLibraryConfigFallback(container, configs, options) { switchBtn.type = 'button'; switchBtn.className = 'btn btn-secondary'; switchBtn.dataset.configAction = 'switch'; - switchBtn.dataset.configKey = config.key; + switchBtn.dataset.configKey = config.key || ''; if (isActive) { switchBtn.dataset.configActive = '1'; } @@ -3755,10 +3698,7 @@ async function showLibraryConfigListV2(options) { // 切换题库配置 async function switchLibraryConfig(configKey) { - const key = typeof configKey === 'string' ? configKey.trim() : ''; - if (!key) { - return; - } + const key = typeof configKey === 'string' && configKey.trim() ? configKey.trim() : null; try { const activeKey = await getActiveLibraryConfigurationKey(); if (activeKey === key) { @@ -3786,10 +3726,6 @@ async function deleteLibraryConfig(configKey) { if (!key) { return; } - if (key === 'exam_index') { - showMessage('默认题库不可删除', 'warning'); - return; - } try { const activeKey = await getActiveLibraryConfigurationKey(); if (activeKey === key) { @@ -3924,12 +3860,12 @@ function openExamWithFallback(exam, delay = 600) { } // Phase 3: 随机练习 - 已迁移到 app-actions.js -function startRandomPractice(category, type = 'reading', filterMode = null, path = null) { +async function startRandomPractice(category, type = 'reading', filterMode = null, path = null) { if (window.AppActions && typeof window.AppActions.startRandomPractice === 'function') { return window.AppActions.startRandomPractice(category, type, filterMode, path); } // 降级:直接执行 - const list = getExamIndexState(); + const list = await resolveActiveExamIndex(); const normalizedType = (!type || type === 'all') ? null : type; const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null; diff --git a/js/presentation/app-actions.js b/js/presentation/app-actions.js index cd75021f..5480b319 100644 --- a/js/presentation/app-actions.js +++ b/js/presentation/app-actions.js @@ -134,11 +134,11 @@ if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') { frequencyScope = 'all'; } - return { + return Promise.resolve({ flowMode: flowMode, frequencyScope: frequencyScope, autoAdvanceAfterSubmit: flowMode !== 'stationary' - }; + }); } function persistSuitePreference(partial) { @@ -146,7 +146,22 @@ if (suitePreferenceUtils && typeof suitePreferenceUtils.persistSuitePreference === 'function') { return suitePreferenceUtils.persistSuitePreference(partial || {}); } - return resolveSuitePreference(partial || {}); + // Fallback persists locally; resolveSuitePreference() above is async, + // but persistSuitePreference itself must remain synchronous so callers + // can read .flowMode/.frequencyScope immediately. Compute inline. + var flowMode = String(partial && partial.flowMode || '').trim().toLowerCase(); + if (flowMode !== 'classic' && flowMode !== 'simulation' && flowMode !== 'stationary') { + flowMode = 'classic'; + } + var frequencyScope = String(partial && partial.frequencyScope || '').trim().toLowerCase(); + if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') { + frequencyScope = 'all'; + } + return { + flowMode: flowMode, + frequencyScope: frequencyScope, + autoAdvanceAfterSubmit: flowMode !== 'stationary' + }; } function persistSuiteFlowMode(mode) { @@ -161,9 +176,9 @@ function promptSuiteModeSelection() { return new Promise(function resolveSelection(resolve) { - var preselectedPreference = resolveSuitePreference(); - var preselected = preselectedPreference.flowMode || 'classic'; - var preselectedScope = preselectedPreference.frequencyScope || 'all'; + resolveSuitePreference().then(function applyPreselection(preselectedPreference) { + var preselected = (preselectedPreference && preselectedPreference.flowMode) || 'classic'; + var preselectedScope = (preselectedPreference && preselectedPreference.frequencyScope) || 'all'; var search = ''; try { search = String(global.location && global.location.search || '').toLowerCase(); @@ -274,6 +289,7 @@ } }); global.document.body.appendChild(host); + }); }); } @@ -379,34 +395,6 @@ } } - function getExamIndexSnapshot() { - if (typeof global.getExamIndexState === 'function') { - try { - var snapshot = global.getExamIndexState(); - if (Array.isArray(snapshot) && snapshot.length) { - return snapshot.slice(); - } - } catch (_) { } - } - if (Array.isArray(global.examIndex) && global.examIndex.length) { - return global.examIndex.slice(); - } - if (typeof global.getReadingExamIndex === 'function') { - var readingIndex = global.getReadingExamIndex(); - if (Array.isArray(readingIndex) && readingIndex.length) { - return readingIndex.map(function (exam) { - return Object.assign({}, exam, { type: exam.type || 'reading' }); - }); - } - } - if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) { - return global.__READING_EXAM_INDEX__.map(function (exam) { - return Object.assign({}, exam, { type: exam.type || 'reading' }); - }); - } - return []; - } - function isReadingMemorizeCandidate(exam) { if (!exam || !exam.id) { return false; @@ -596,12 +584,8 @@ }); } - function startRandomPractice(category, type, filterMode, path) { - var getExamIndexState = global.getExamIndexState || function () { - return Array.isArray(global.examIndex) ? global.examIndex : []; - }; - - var list = getExamIndexState(); + async function startRandomPractice(category, type, filterMode, path) { + var list = await global.resolveActiveLibraryIndex(); var normalizedType = (!type || type === 'all') ? null : type; var normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null; @@ -702,11 +686,8 @@ }, 1000); } - function pickRandomExam() { - var getExamIndexState = global.getExamIndexState || function () { - return Array.isArray(global.examIndex) ? global.examIndex : []; - }; - var list = getExamIndexState().filter(function (e) { + function pickRandomExam(examIndex) { + var list = (Array.isArray(examIndex) ? examIndex : []).filter(function (e) { return e && e.hasHtml && e.type === 'reading'; }); if (!list.length) return null; @@ -728,6 +709,9 @@ } // resolve to absolute url = new URL(url, window.location.href).href; + var parsedUrl = new URL(url); + parsedUrl.searchParams.set('endless', '1'); + url = parsedUrl.href; } catch (_) { } if (!url) return null; @@ -752,14 +736,21 @@ if (!endlessState || !endlessState.active) return; var countdown = ENDLESS_COUNTDOWN_SEC; + var postEndlessControl = function (type, data) { + if (!endlessState || !endlessState.currentExamId || !global.app + || typeof global.app._postExamMessage !== 'function') return false; + return global.app._postExamMessage( + endlessState.currentExamId, + sourceWindow, + type, + data || {} + ); + }; // 通知练习页开始倒计时 try { if (sourceWindow && !sourceWindow.closed) { - sourceWindow.postMessage({ - type: 'ENDLESS_COUNTDOWN', - data: { seconds: countdown } - }, '*'); + postEndlessControl('ENDLESS_COUNTDOWN', { seconds: countdown }); } } catch (_) { } @@ -780,10 +771,7 @@ // 持续更新倒计时 try { if (sourceWindow && !sourceWindow.closed) { - sourceWindow.postMessage({ - type: 'ENDLESS_COUNTDOWN_TICK', - data: { seconds: countdown } - }, '*'); + postEndlessControl('ENDLESS_COUNTDOWN_TICK', { seconds: countdown }); } } catch (_) { } @@ -793,16 +781,13 @@ try { if (sourceWindow && !sourceWindow.closed) { - sourceWindow.postMessage({ - type: 'ENDLESS_COUNTDOWN_END', - data: {} - }, '*'); + postEndlessControl('ENDLESS_COUNTDOWN_END', {}); } } catch (_) { } if (!endlessState || !endlessState.active) return; - var nextExam = pickRandomExam(); + var nextExam = pickRandomExam(endlessState.examIndex); if (!nextExam) { if (typeof global.showMessage === 'function') { global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a', 'warning'); @@ -816,21 +801,32 @@ } var reuseWin = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null; - var newWin = openEndlessExam(nextExam, reuseWin); - if (newWin) { - endlessState.currentWindow = newWin; - if (global.app && typeof global.app.setupExamWindowManagement === 'function') { - global.app.setupExamWindowManagement(newWin, nextExam.id, nextExam, {}); + var openNext = global.app && typeof global.app.openExam === 'function' + ? global.app.openExam(nextExam.id, { + target: 'tab', + windowName: ENDLESS_WINDOW_NAME, + reuseWindow: reuseWin, + endlessMode: true + }) + : openEndlessExam(nextExam, reuseWin); + Promise.resolve(openNext).then(function (newWin) { + if (!newWin || !endlessState || !endlessState.active) { + throw new Error('无法打开下一题'); } - if (global.app && typeof global.app.startPracticeSession === 'function') { - try { global.app.startPracticeSession(nextExam.id); } catch (_) { } + endlessState.currentWindow = newWin; + endlessState.currentExamId = nextExam.id; + }).catch(function (error) { + if (global.console && console.error) console.error('[EndlessMode] 打开下一题失败:', error); + if (typeof global.showMessage === 'function') { + global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u4e0b\u4e00\u9898', 'error'); } - } + stopEndlessPractice({ silent: true }); + }); } }, 1000); } - function startEndlessPractice() { + async function startEndlessPractice() { // 如果已激活,不再走“父页按钮二次点击退出”的伪交互 if (endlessState && endlessState.active) { if (typeof global.showMessage === 'function') { @@ -839,7 +835,8 @@ return; } - var firstExam = pickRandomExam(); + var examIndex = await global.resolveActiveLibraryIndex(); + var firstExam = pickRandomExam(examIndex); if (!firstExam) { if (typeof global.showMessage === 'function') { global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a\uff0c\u8bf7\u5148\u52a0\u8f7d\u9898\u5e93', 'error'); @@ -850,8 +847,10 @@ // 标记状态 endlessState = { active: true, + examIndex: examIndex, countdownTimer: null, currentWindow: null, + currentExamId: firstExam.id, messageHandler: null, windowMonitor: null }; @@ -861,6 +860,25 @@ if (!endlessState || !endlessState.active) return; var msg = event && event.data; if (!msg || typeof msg.type !== 'string') return; + var currentWindow = endlessState.currentWindow; + if (!currentWindow || event.source !== currentWindow) return; + var info = global.app && global.app.examWindows && endlessState.currentExamId + ? global.app.examWindows.get(endlessState.currentExamId) + : null; + if (info && info.expectedOrigin && info.expectedOrigin !== 'null') { + if (event.origin !== info.expectedOrigin) return; + } else if (info && info.allowOpaqueOrigin) { + if (event.origin !== 'null') return; + } else { + return; + } + var messageData = msg.data || {}; + var permitsPreInit = msg.type === 'REQUEST_INIT'; + if (!permitsPreInit && ( + msg.source !== 'practice_page' + || !info.windowSessionToken + || messageData.windowSessionToken !== info.windowSessionToken + )) return; if (msg.type === 'ENDLESS_USER_EXIT') { stopEndlessPractice(); return; @@ -894,19 +912,27 @@ // 优先用 app.openExam 保证注入 if (global.app && typeof global.app.openExam === 'function') { try { - Promise.resolve(global.app.openExam(firstExam.id, { + win = await global.app.openExam(firstExam.id, { target: 'tab', - windowName: ENDLESS_WINDOW_NAME - })).then(function (w) { - if (w && endlessState) endlessState.currentWindow = w; - startEndlessWindowMonitor(); - }).catch(function () { }); - } catch (_) { } + windowName: ENDLESS_WINDOW_NAME, + endlessMode: true + }); + } catch (error) { + if (global.console && console.error) console.error('[EndlessMode] 打开首题失败:', error); + } } else { win = openEndlessExam(firstExam, null); - if (win && endlessState) endlessState.currentWindow = win; - startEndlessWindowMonitor(); } + if (!win || !endlessState) { + stopEndlessPractice({ silent: true }); + if (typeof global.showMessage === 'function') { + global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u7ec3\u4e60\u7a97\u53e3', 'error'); + } + return; + } + endlessState.currentWindow = win; + endlessState.currentExamId = firstExam.id; + startEndlessWindowMonitor(); } global.AppActions = Object.assign({}, global.AppActions, { diff --git a/js/runtime/lazyLoader.js b/js/runtime/lazyLoader.js index 283b9520..953f98a0 100644 --- a/js/runtime/lazyLoader.js +++ b/js/runtime/lazyLoader.js @@ -42,9 +42,7 @@ 'js/bundles/theme.bundle.js' ]; - manifest['settings-tools'] = [ - 'js/bundles/settings.bundle.js' - ]; + manifest['settings-tools'] = []; manifest['diagnostics-tools'] = [ 'js/bundles/diagnostics.bundle.js' @@ -53,13 +51,16 @@ dependencies['state-core'] = []; dependencies['exam-data'] = []; dependencies['practice-suite'] = ['state-core']; - dependencies['browse-runtime'] = ['state-core']; - dependencies['browse-view'] = ['state-core']; + // Browsing is also the entry point for starting a practice session. + // Keep the real recorder ready before a user can open an exam; the + // bootstrap fallback cannot own the full submit/persist round trip. + dependencies['browse-runtime'] = ['state-core', 'practice-suite']; + dependencies['browse-view'] = ['state-core', 'practice-suite']; dependencies['session-suite'] = ['browse-runtime', 'practice-suite']; dependencies['settings-tools'] = ['state-core']; - dependencies['more-tools'] = ['state-core', 'settings-tools']; + dependencies['more-tools'] = ['state-core']; dependencies['theme-tools'] = []; - dependencies['diagnostics-tools'] = ['state-core', 'settings-tools']; + dependencies['diagnostics-tools'] = ['state-core']; } function setBuiltInListeningAvailability(available, reason) { diff --git a/js/runtime/readingHighlightShared.js b/js/runtime/readingHighlightShared.js index 4008f815..96ec00e3 100644 --- a/js/runtime/readingHighlightShared.js +++ b/js/runtime/readingHighlightShared.js @@ -175,6 +175,7 @@ scope, text, kind: resolveHighlightKind(node), + noteId: node.dataset && node.dataset.noteId ? String(node.dataset.noteId) : '', occurrence: seen, start: startOffset, end: endOffset, @@ -248,6 +249,9 @@ if (offsetRange && !offsetRange.collapsed) { const offsetSpan = document.createElement('span'); applyHighlightKind(offsetSpan, highlightKind); + if (record.noteId) { + offsetSpan.dataset.noteId = String(record.noteId); + } try { offsetRange.surroundContents(offsetSpan); return true; @@ -296,6 +300,9 @@ } const span = document.createElement('span'); applyHighlightKind(span, highlightKind); + if (record.noteId) { + span.dataset.noteId = String(record.noteId); + } try { range.surroundContents(span); return true; diff --git a/js/runtime/reviewHighlightDictionary.js b/js/runtime/reviewHighlightDictionary.js index 3590df22..6aec70c8 100644 --- a/js/runtime/reviewHighlightDictionary.js +++ b/js/runtime/reviewHighlightDictionary.js @@ -5,12 +5,12 @@ const BUBBLE_ID = 'review-highlight-dictionary-bubble'; const INTERACTIVE_CLASS = 'review-dictionary-highlight'; const VOCAB_MESSAGE_TYPE = 'VOCAB_HIGHLIGHT_SAVE'; - const FALLBACK_STORAGE_KEY = 'exam_system_vocab_list_reading_highlights'; let currentOptions = {}; let activeHighlight = null; let activeLookup = null; let outsideHandlerAttached = false; + const pendingSaveRequests = new Map(); function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); @@ -445,48 +445,12 @@ }; } - function createStorageEnvelope(data) { - return JSON.stringify({ - data, - timestamp: Date.now(), - version: '0.6.2-fix', - compressed: false - }); - } - - function readFallbackList() { - try { - const raw = global.localStorage && global.localStorage.getItem(FALLBACK_STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw); - const data = parsed && Object.prototype.hasOwnProperty.call(parsed, 'data') - ? parsed.data - : parsed; - return data && typeof data === 'object' && Array.isArray(data.words) ? data : null; - } catch (_) { - return null; - } - } - - function writeFallbackVocab(payload) { - if (!global.localStorage || !payload || !payload.word) { - return false; - } + async function writeAppDataVocab(payload) { + if (!payload || !payload.word || !global.AppData || !global.AppData.vocab) return false; + const key = String(payload.word).trim().toLowerCase(); const now = new Date().toISOString(); - const list = readFallbackList() || { - id: 'reading-highlights', - name: '阅读高亮生词', - icon: '📖', - source: 'reading-highlight', - words: [], - createdAt: now, - updatedAt: now - }; - const key = payload.word.toLowerCase(); - const existingIndex = list.words.findIndex((item) => String(item.word || '').trim().toLowerCase() === key); - const wordRecord = { + await global.AppData.ready; + await global.AppData.vocab.upsertCollectionWord('reading-highlights', { id: `reading-highlight-${key.replace(/[^a-z0-9]+/g, '-')}`, word: payload.word, meaning: payload.meaning || payload.definition || '待补充释义', @@ -497,7 +461,6 @@ payload.selectedText && payload.selectedText !== payload.word ? `原高亮: ${payload.selectedText}` : '', payload.sourceLabel ? `来源: ${payload.sourceLabel}` : '' ].filter(Boolean).join(';'), - timestamp: Date.now(), source: 'reading-highlight', easeFactor: null, interval: 1, @@ -506,59 +469,74 @@ correctCount: 0, lastReviewed: null, nextReview: null, - createdAt: existingIndex >= 0 ? (list.words[existingIndex].createdAt || now) : now, updatedAt: now - }; - if (existingIndex >= 0) { - list.words.splice(existingIndex, 1, { ...list.words[existingIndex], ...wordRecord }); - } else { - list.words.push(wordRecord); - } - list.updatedAt = now; - list.stats = { - totalWords: list.words.length, - masteredWords: list.words.filter((word) => (Number(word.correctCount) || 0) >= 4).length, - reviewingWords: list.words.filter((word) => word.lastReviewed && !word.nextReview).length - }; - global.localStorage.setItem(FALLBACK_STORAGE_KEY, createStorageEnvelope(list)); + }); return true; } + function createRequestId() { + try { + if (global.crypto && typeof global.crypto.randomUUID === 'function') { + return `vocab-highlight-${global.crypto.randomUUID()}`; + } + } catch (_) { + // use timestamp fallback + } + return `vocab-highlight-${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + function settleSaveRequest(requestId, succeeded) { + const id = String(requestId || '').trim(); + const pending = pendingSaveRequests.get(id); + if (!id || !pending) return false; + pendingSaveRequests.delete(id); + clearTimeout(pending.timer); + pending.resolve(Boolean(succeeded)); + return true; + } + + function handleSaveOutcome(payload, succeeded) { + const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : ''; + return settleSaveRequest(requestId, succeeded); + } + function postVocabPayload(payload) { - if (currentOptions && typeof currentOptions.postMessage === 'function') { - currentOptions.postMessage(VOCAB_MESSAGE_TYPE, payload); - return true; + if (!currentOptions || typeof currentOptions.postMessage !== 'function') return null; + const requestId = createRequestId(); + const requestPayload = { ...payload, requestId }; + const outcome = new Promise((resolve) => { + const timer = setTimeout(() => { + pendingSaveRequests.delete(requestId); + resolve(false); + }, 5000); + pendingSaveRequests.set(requestId, { resolve, timer }); + }); + let delivered = false; + try { + delivered = currentOptions.postMessage(VOCAB_MESSAGE_TYPE, requestPayload) !== false; + } catch (_) { + delivered = false; } - const candidates = [global.opener, global.parent]; - for (let index = 0; index < candidates.length; index += 1) { - const target = candidates[index]; - if (!target || target === global) { - continue; - } - try { - target.postMessage({ - type: VOCAB_MESSAGE_TYPE, - source: 'practice_page', - data: payload - }, '*'); - return true; - } catch (_) { - // try next target - } + if (!delivered) { + settleSaveRequest(requestId, false); + return null; } - return false; + return outcome; } - function saveActiveLookup(button) { + async function saveActiveLookup(button) { const payload = buildVocabPayload(); if (!payload.word) { return; } - const posted = postVocabPayload(payload); - const fallbackSaved = writeFallbackVocab(payload); + const hostOutcome = postVocabPayload(payload); + let persisted = hostOutcome ? await hostOutcome : false; + if (!persisted) { + try { persisted = await writeAppDataVocab(payload); } catch (_) { persisted = false; } + } if (button instanceof HTMLButtonElement) { - button.textContent = posted || fallbackSaved ? '已加入' : '保存失败'; - button.disabled = true; + button.textContent = persisted ? '已加入' : '保存失败'; + button.disabled = persisted; } } @@ -610,7 +588,7 @@ attach, enhance, close: closeBubble, - storageKey: FALLBACK_STORAGE_KEY, + handleSaveOutcome, messageType: VOCAB_MESSAGE_TYPE }; diff --git a/js/runtime/unifiedReadingPage.js b/js/runtime/unifiedReadingPage.js index 4faacf4a..1cdbd57d 100644 --- a/js/runtime/unifiedReadingPage.js +++ b/js/runtime/unifiedReadingPage.js @@ -4,12 +4,33 @@ const MESSAGE_SOURCE = 'practice_page'; const INIT_RETRY_MS = 1500; const SIMULATION_DRAFT_SYNC_MS = 1200; + const READING_DRAFT_SYNC_MS = 1500; + const SUBMIT_ACK_TIMEOUT_MS = 10000; + const NOTE_EDITOR_SAVE_DEBOUNCE_MS = 450; + const NOTE_ROW_LONG_PRESS_MS = 100; const EXPLANATION_STYLE_ID = 'reading-explanation-style'; const MEMORIZE_STYLE_ID = 'reading-memorize-style'; + const READING_NOTE_STYLE_ID = 'reading-note-style'; + const READING_DISPLAY_CONTROL_STYLE_ID = 'reading-display-control-style'; const PRACTICE_TIMER_BRIDGE_KEY = '__IELTS_PRACTICE_TIMER__'; const PRACTICE_TIMER_EVENT = 'practiceTimerStateChange'; - const READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1'; const READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/; + const HOST_MESSAGE_SOURCE = 'exam_host'; + let readingCandidateCodeCache = { mode: 'auto', customCode: '' }; + + function deriveReferrerOrigin() { + try { + if (!document.referrer) return ''; + const parsed = new URL(document.referrer, global.location.href); + // File-page refs do not provide a usable web origin, so bind them through + // the opaque/file message-origin handling below instead of pinning file://. + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (_) { + return ''; + } + } const EXPLANATION_NODE_SELECTOR = [ '.reading-explanation-card', '.reading-group-explanation', @@ -26,6 +47,7 @@ const navStatus = new Map(); const scriptCache = new Map(); const LOCATOR_HIGHLIGHT_SELECTOR = '.reading-locator-highlight, .reading-locator-block'; + const LOCATOR_OVERLAP_SELECTOR = '.reading-locator-overlap'; function getAnswerMatchCore() { const core = global.AnswerMatchCore; if (!core || typeof core !== 'object') { @@ -79,6 +101,10 @@ timerLocked: false, ready: false, submitted: false, + submissionStatus: 'draft', + submissionId: '', + submissionAckTimer: null, + pendingSubmissionPresentation: null, initTimer: null, manifestLoaded: false, dataset: null, @@ -100,10 +126,37 @@ }, simulationDraftSyncTimer: null, simulationDraftFingerprint: '', + readingDraftSyncTimer: null, + readingDraftFingerprint: '', + notes: [], + noteOutlines: [], + markedQuestions: [], + activeNoteId: '', + noteEditorPosition: null, + noteUiInitialized: false, + noteEditorSaveTimer: null, + noteDrawerDirty: true, + noteHighlightMetaDirty: true, + noteEditorPendingSync: false, + reviewRecordId: '', + // 单篇阅读 final-submit 成功后,宿主通过 PRACTICE_RECORD_SAVED 回传的已存档 + // practice record id。持有该 id 时,笔记编辑在只读提交页仍然可写,并且 + // syncReadingAnnotation 会以该 recordId 发送 READING_ANNOTATION_SYNC,把 + // 结果页上的笔记改动持久化回已存档的练习记录。 + submittedRecordId: '', + highlightVisibility: { + locators: true, + notes: true, + highlights: true + }, + questionNavCollapsed: false, lastInitSignature: '', lastReplaySignature: '', sessionReadySent: false, parentWindow: global.opener || global.parent || null, + expectedParentOrigin: deriveReferrerOrigin(), + parentOrigin: '', + parentOriginIsOpaque: false, windowSessionToken: '', windowSessionIssuedAtMs: 0 }; @@ -128,7 +181,10 @@ timerInterval: null, lastRange: null, currentHighlightNode: null, - keepToolbar: false + keepToolbar: false, + noteDragFrame: null, + noteListDragging: false, + noteSuppressClickUntil: 0 }; const testOverrides = { renderExplanations: null @@ -297,6 +353,10 @@ control.disabled = locked || state.readOnly; } }); + if (dom.resetBtn) dom.resetBtn.disabled = locked || state.readOnly; + document.querySelectorAll('#reading-note-drawer [data-note-outline-add], #reading-note-drawer [data-note-outline-toggle], #reading-note-drawer [data-note-outline-title], #reading-note-drawer [data-note-outline-delete], #reading-note-drawer [data-note-drag-handle], #reading-note-drawer [data-note-delete]').forEach((control) => { + if ('disabled' in control) control.disabled = locked; + }); disableDragInteractions(); } @@ -333,20 +393,15 @@ } function readReadingCandidateCodePreferences() { - try { - const raw = global.localStorage?.getItem(READING_CANDIDATE_CODE_PREF_KEY); - const parsed = raw ? JSON.parse(raw) : null; - const mode = parsed?.mode === 'custom' ? 'custom' : 'auto'; - const customCode = typeof parsed?.customCode === 'string' - ? parsed.customCode.replace(/\D/g, '').slice(0, 6) - : ''; - return { - mode, - customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' - }; - } catch (_) { - return { mode: 'auto', customCode: '' }; - } + return { ...readingCandidateCodeCache }; + } + + async function loadReadingCandidateCodePreferences() { + await global.AppData.ready; + const stored = await global.AppData.preferences.getCandidateCode(); + const mode = stored?.mode === 'custom' ? 'custom' : 'auto'; + const customCode = typeof stored?.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : ''; + readingCandidateCodeCache = { mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' }; } function resolveReadingCandidateCode() { @@ -367,6 +422,8 @@ const rawLimitSeconds = Number(state.suiteTimerLimitSeconds); if (Number.isFinite(rawLimitSeconds) && rawLimitSeconds > 0) { limitSeconds = Math.floor(rawLimitSeconds); + } else if (state.suiteSessionId && state.suiteTimerMode === 'countdown') { + limitSeconds = minutesToSeconds(60, 60); } else if (preferences.limitEnabled) { limitSeconds = minutesToSeconds(preferences.limitMinutes, 60); } else { @@ -404,8 +461,10 @@ } timer.classList.toggle('paused', !interaction.timerRunning && !hasEndlessCountdown); timer.classList.toggle('timer-expired', expired); - timer.dataset.timerMode = preferences.mode; - timer.dataset.expiryAction = preferences.expiryAction; + if (timer.dataset) { + timer.dataset.timerMode = preferences.mode; + timer.dataset.expiryAction = preferences.expiryAction; + } timer.style.opacity = (interaction.timerRunning || hasEndlessCountdown) ? '1' : '0.5'; var _warnRemaining = !hasEndlessCountdown && (preferences.mode === 'countdown' || (Number.isFinite(Number(limitSeconds)) && Number(limitSeconds) > 0)) @@ -533,6 +592,10 @@ function updateSelectionToolbar() { const toolbar = document.getElementById('selbar'); if (!toolbar) return; + if (!canEditReadingNotes()) { + toolbar.style.display = 'none'; + return; + } const selection = global.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { if (!interaction.keepToolbar && !interaction.currentHighlightNode) { @@ -594,6 +657,10 @@ function applySelectionHighlight(kind = 'highlight') { const toolbar = document.getElementById('selbar'); + if (!canEditReadingNotes()) { + if (toolbar) toolbar.style.display = 'none'; + return; + } const selection = global.getSelection(); if (!interaction.lastRange || interaction.lastRange.collapsed || interaction.currentHighlightNode) { return; @@ -612,11 +679,19 @@ if (toolbar) toolbar.style.display = 'none'; interaction.lastRange = null; interaction.currentHighlightNode = null; - syncSimulationDraftSnapshot('highlight'); + if (kind === 'note') { + const note = ensureNoteForHighlight(span, normalizeNoteText(span.textContent), { sync: false }); + if (note) openNoteEditor(note.id, { anchorNode: span, focusBody: true }); + } + syncReadingAnnotation('highlight'); } function removeSelectionHighlight() { const toolbar = document.getElementById('selbar'); + if (!canEditReadingNotes()) { + if (toolbar) toolbar.style.display = 'none'; + return; + } const selection = global.getSelection(); let target = interaction.currentHighlightNode; if (!target && interaction.lastRange) { @@ -625,6 +700,7 @@ ? ancestor.parentElement?.closest('.hl') : ancestor.closest?.('.hl'); } + const removedNoteId = target instanceof HTMLElement ? String(target.dataset.noteId || '') : ''; if (target && target.parentNode) { const parent = target.parentNode; while (target.firstChild) { @@ -637,7 +713,8 @@ if (toolbar) toolbar.style.display = 'none'; interaction.lastRange = null; interaction.currentHighlightNode = null; - syncSimulationDraftSnapshot('unhighlight'); + if (removedNoteId) deleteNote(removedNoteId, { sync: false }); + syncReadingAnnotation('unhighlight'); } function attachSelectionHighlightToolbar() { @@ -654,13 +731,13 @@ }); document.getElementById('btnHL')?.addEventListener('click', () => applySelectionHighlight('highlight')); document.getElementById('btnNote')?.addEventListener('click', () => { + if (!canEditReadingNotes()) return; let targetNode = interaction.currentHighlightNode; let text = ''; if (targetNode) { if (targetNode.dataset.hlType !== 'note') { targetNode.dataset.hlType = 'note'; - syncSimulationDraftSnapshot('highlight'); } text = (targetNode.textContent || '').trim(); } else if (interaction.lastRange && !interaction.lastRange.collapsed) { @@ -684,18 +761,10 @@ interaction.lastRange = null; interaction.currentHighlightNode = null; - if (text) { - const noteArea = document.querySelector('#notes-panel textarea'); - if (noteArea) { - noteArea.value += (noteArea.value ? '\n\n' : '') + '> ' + text + '\n'; - noteArea.scrollTop = noteArea.scrollHeight; - noteArea.focus(); - } + if (targetNode && text) { + const note = ensureNoteForHighlight(targetNode, text); closeFloatingPanels(); - const notesPanel = document.getElementById('notes-panel'); - const overlay = document.querySelector('.overlay'); - if (notesPanel) notesPanel.style.display = 'flex'; - if (overlay) overlay.style.display = 'block'; + if (note) openNoteEditor(note.id, { anchorNode: targetNode, focusBody: true }); } }); document.getElementById('btnUH')?.addEventListener('click', removeSelectionHighlight); @@ -983,6 +1052,9 @@ } function getNotesText() { + if (state.noteUiInitialized) { + return formatNotesForLegacyText(state.notes); + } const noteArea = document.querySelector('#notes-panel textarea'); return noteArea ? String(noteArea.value || '') : ''; } @@ -994,11 +1066,164 @@ } } + function generateNoteId() { + return `note_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + } + + function generateNoteOutlineId() { + return `outline_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + } + + function normalizeNoteText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function buildDefaultNoteTitle(quote = '') { + const text = normalizeNoteText(quote); + if (!text) return 'Untitled note'; + return text.length > 36 ? `${text.slice(0, 36)}...` : text; + } + + function compareNoteOrder(a, b) { + const orderA = Number.isFinite(Number(a?.order)) ? Number(a.order) : 0; + const orderB = Number.isFinite(Number(b?.order)) ? Number(b.order) : 0; + if (orderA !== orderB) return orderA - orderB; + return Number(a?.createdAt || 0) - Number(b?.createdAt || 0); + } + + function normalizeNotes(rawNotes) { + const seen = new Set(); + return (Array.isArray(rawNotes) ? rawNotes : []).map((entry, index) => { + if (!entry || typeof entry !== 'object') return null; + let id = entry.id != null ? String(entry.id).trim() : ''; + if (!id || seen.has(id)) id = generateNoteId(); + seen.add(id); + const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now(); + return { + id, + title: entry.title != null ? String(entry.title) : '', + body: entry.body != null ? String(entry.body) : '', + quote: entry.quote != null ? String(entry.quote) : '', + outlineId: entry.outlineId != null ? String(entry.outlineId).trim() : '', + order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index, + createdAt, + updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt + }; + }).filter(Boolean); + } + + function normalizeNoteOutlines(rawOutlines) { + const seen = new Set(); + return (Array.isArray(rawOutlines) ? rawOutlines : []).map((entry, index) => { + if (!entry || typeof entry !== 'object') return null; + let id = entry.id != null ? String(entry.id).trim() : ''; + if (!id || seen.has(id)) id = generateNoteOutlineId(); + seen.add(id); + const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now(); + return { + id, + title: String(entry.title || '').trim() || 'New outline', + order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index, + collapsed: Boolean(entry.collapsed), + createdAt, + updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt + }; + }).filter(Boolean).sort(compareNoteOrder); + } + + function sanitizeNotesWithOutlines(rawNotes, rawOutlines) { + const noteOutlines = normalizeNoteOutlines(rawOutlines); + const validIds = new Set(noteOutlines.map((outline) => outline.id)); + const notes = normalizeNotes(rawNotes).map((note, index) => ({ + ...note, + outlineId: validIds.has(note.outlineId) ? note.outlineId : '', + order: Number.isFinite(Number(note.order)) ? Number(note.order) : index + })); + return { notes, noteOutlines }; + } + + function collectNotes() { + return normalizeNotes(state.notes); + } + + function collectNoteOutlines() { + return normalizeNoteOutlines(state.noteOutlines); + } + + function getNoteById(noteId) { + const id = String(noteId || '').trim(); + return id ? state.notes.find((note) => note && note.id === id) || null : null; + } + + function getValidNoteOutlineId(outlineId) { + const id = String(outlineId || '').trim(); + return id && state.noteOutlines.some((outline) => outline.id === id) ? id : ''; + } + + function sortNotesForDrawer(notes = state.notes) { + return (Array.isArray(notes) ? notes : []).filter(Boolean).slice().sort(compareNoteOrder); + } + + function getNextNoteOrder(outlineId = '') { + const id = getValidNoteOutlineId(outlineId); + const matching = state.notes.filter((note) => (note?.outlineId || '') === id); + return matching.length + ? Math.max(...matching.map((note) => Number.isFinite(Number(note.order)) ? Number(note.order) : 0)) + 1 + : 0; + } + + function formatNotesForLegacyText(notes = state.notes) { + return normalizeNotes(notes).map((note) => { + const parts = [`# ${String(note.title || '').trim() || 'Untitled note'}`]; + if (note.quote) parts.push(`> ${normalizeNoteText(note.quote)}`); + if (note.body) parts.push(note.body); + return parts.join('\n'); + }).join('\n\n'); + } + + function syncNotesToLegacyText() { + setNotesText(formatNotesForLegacyText(state.notes)); + } + + function normalizeMarkedQuestions(rawQuestions) { + const seen = new Set(); + return (Array.isArray(rawQuestions) ? rawQuestions : []).map((entry) => ( + normalizeQuestionId(entry) || String(entry || '').trim().toLowerCase() + )).filter(Boolean).filter((entry) => { + if (seen.has(entry)) return false; + seen.add(entry); + return true; + }); + } + + function getCurrentMarkedQuestions() { + let marks = []; + let hostResolved = false; + if (typeof global.getPracticeMarkedQuestions === 'function') { + try { + const raw = global.getPracticeMarkedQuestions(); + hostResolved = raw != null; + marks = normalizeMarkedQuestions(raw); + } catch (_) { marks = []; } + } + // 只有当 host 没有 give 出结果时(函数不存在或抛错)才回退到缓存; + // 用户清空最后一个标记时 host 会返回 [],这是有效空集,不能再被 state.markedQuestions 复活, + // 否则清空无法持久,并会在后续 draft/annotation sync 中重新写入旧标记。 + if (!hostResolved && !marks.length) { + marks = normalizeMarkedQuestions(state.markedQuestions); + } + state.markedQuestions = marks.slice(); + return marks; + } + function buildEmptyDraft() { return { answers: {}, highlights: [], noteText: '', + notes: [], + noteOutlines: [], + markedQuestions: [], scrollY: 0, updatedAt: Date.now() }; @@ -1016,6 +1241,9 @@ noteText: typeof source.noteText === 'string' ? source.noteText : '', + notes: normalizeNotes(source.notes), + noteOutlines: normalizeNoteOutlines(source.noteOutlines), + markedQuestions: normalizeMarkedQuestions(source.markedQuestions), scrollY: Number.isFinite(Number(source.scrollY)) ? Number(source.scrollY) : 0, @@ -1044,7 +1272,7 @@ const mergedUpdatedAt = Number.isFinite(Number(next.updatedAt)) ? Number(next.updatedAt) : (Number.isFinite(Number(base.updatedAt)) ? Number(base.updatedAt) : Date.now()); - return Object.assign(buildEmptyDraft(), base, next, { + const merged = Object.assign(buildEmptyDraft(), base, next, { answers: next.answers && typeof next.answers === 'object' ? { ...next.answers } : { ...base.answers }, @@ -1054,11 +1282,22 @@ noteText: typeof next.noteText === 'string' ? next.noteText : base.noteText, + notes: Array.isArray(nextDraft?.notes) ? normalizeNotes(next.notes) : normalizeNotes(base.notes), + noteOutlines: Array.isArray(nextDraft?.noteOutlines) + ? normalizeNoteOutlines(next.noteOutlines) + : normalizeNoteOutlines(base.noteOutlines), + markedQuestions: Array.isArray(nextDraft?.markedQuestions) + ? normalizeMarkedQuestions(next.markedQuestions) + : normalizeMarkedQuestions(base.markedQuestions), scrollY: Number.isFinite(Number(next.scrollY)) ? Number(next.scrollY) : base.scrollY, updatedAt: mergedUpdatedAt }); + const sanitized = sanitizeNotesWithOutlines(merged.notes, merged.noteOutlines); + merged.notes = sanitized.notes; + merged.noteOutlines = sanitized.noteOutlines; + return merged; } function mergeSuiteDraftPayload(data = {}) { @@ -1157,6 +1396,9 @@ answers: collectAnswers(), highlights: collectHighlights(), noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), scrollY: global.scrollY || 0, updatedAt: Date.now() }); @@ -1320,7 +1562,6 @@ refreshDynamicQuestionEnhancements(); clearCurrentAnswers(); applyDraftToDom(slot.draft || buildEmptyDraft()); - setNotesText(slot.draft?.noteText || ''); syncSimulationCtxForActiveSlot(); syncInlineSuiteIdentity(); state.simulationMode = true; @@ -1352,6 +1593,867 @@ return global.__READING_EXPLANATION_MANIFEST__ || {}; } + function ensureReadingDisplayControlStyles() { + if (document.getElementById(READING_DISPLAY_CONTROL_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = READING_DISPLAY_CONTROL_STYLE_ID; + style.textContent = ` + .reading-display-toggle-group{display:inline-flex;align-items:center;gap:4px;padding:2px;border:1px solid #dbe4ef;border-radius:8px;background:#f8fafc} + .reading-display-toggle{border:0;border-radius:6px;min-width:30px;height:28px;padding:0 8px;cursor:pointer;color:#64748b;background:transparent;font-size:12px;font-weight:700} + .reading-display-toggle:hover{background:#eef2f7;color:#0f172a}.reading-display-toggle.is-on{background:#dbeafe;color:#1d4ed8} + body.hide-reading-locators .reading-locator-highlight{background:transparent!important;box-shadow:none!important;outline:none!important} + body.hide-reading-locators .reading-locator-overlap{text-decoration:none!important;outline:none!important} + body.hide-reading-locators .reading-passage-locator-target.is-review-jump-target{background:transparent!important;outline:none!important} + body.hide-reading-notes .hl[data-hl-type="note"],body.hide-reading-notes .hl[data-note-id]{background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important;pointer-events:none} + body.hide-reading-highlights .hl:not([data-hl-type="note"]):not([data-note-id]){background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important} + body.reading-question-nav-collapsed .practice-nav{display:none} + body.dark-mode .reading-display-toggle-group{background:#1e293b;border-color:#475569;color:#cbd5e1} + `; + document.head.appendChild(style); + } + + function saveReadingDisplayPreferences() { + global.AppData.preferences.setReadingDisplay({ + highlightVisibility: state.highlightVisibility, + questionNavCollapsed: state.questionNavCollapsed + }).catch((error) => console.warn('[ReadingDisplay] 保存失败:', error)); + } + + async function loadReadingDisplayPreferences() { + try { + const saved = await global.AppData.preferences.getReadingDisplay(); + if (saved?.highlightVisibility) { + state.highlightVisibility = { + locators: saved.highlightVisibility.locators !== false, + notes: saved.highlightVisibility.notes !== false, + highlights: saved.highlightVisibility.highlights !== false + }; + } + state.questionNavCollapsed = Boolean(saved?.questionNavCollapsed); + } catch (_) { /* Ignore invalid preference payloads. */ } + applyReadingDisplayState(); + } + + function applyReadingDisplayState() { + if (!document.body) return; + document.body.classList.toggle('hide-reading-locators', state.highlightVisibility.locators === false); + document.body.classList.toggle('hide-reading-notes', state.highlightVisibility.notes === false); + document.body.classList.toggle('hide-reading-highlights', state.highlightVisibility.highlights === false); + document.body.classList.toggle('reading-question-nav-collapsed', state.questionNavCollapsed); + document.querySelectorAll('[data-highlight-toggle]').forEach((button) => { + const key = button.getAttribute('data-highlight-toggle'); + const enabled = state.highlightVisibility[key] !== false; + button.classList.toggle('is-on', enabled); + button.setAttribute('aria-pressed', enabled ? 'true' : 'false'); + }); + const navToggle = document.getElementById('reading-question-nav-toggle'); + if (navToggle) { + const collapsed = state.questionNavCollapsed; + // is-on means the question card bar is currently visible. + navToggle.classList.toggle('is-on', !collapsed); + navToggle.setAttribute('aria-pressed', collapsed ? 'false' : 'true'); + navToggle.title = collapsed ? '显示题卡' : '隐藏题卡'; + navToggle.textContent = 'Q'; + } + } + + function ensureReadingDisplayControls() { + ensureReadingDisplayControlStyles(); + // Remove the legacy floating bottom-right nav toggle if an older session left one behind. + document.querySelectorAll('body > #reading-question-nav-toggle, body > .reading-question-nav-toggle').forEach((node) => { + if (node.closest?.('.reading-display-toggle-group')) return; + node.remove(); + }); + const headerRight = document.querySelector('.header-right'); + if (headerRight && !document.getElementById('reading-display-toggle-group')) { + const group = document.createElement('div'); + group.id = 'reading-display-toggle-group'; + group.className = 'reading-display-toggle-group'; + group.setAttribute('aria-label', '阅读显示控制'); + group.innerHTML = [ + '', + '', + '', + '' + ].join(''); + const settingsButton = document.getElementById('settings-btn'); + headerRight.insertBefore(group, settingsButton?.parentNode === headerRight ? settingsButton : null); + group.addEventListener('click', (event) => { + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target) return; + const navButton = target.closest('[data-question-nav-toggle]'); + if (navButton) { + state.questionNavCollapsed = !state.questionNavCollapsed; + applyReadingDisplayState(); + saveReadingDisplayPreferences(); + return; + } + const button = target.closest('[data-highlight-toggle]'); + if (!button) return; + const key = button.getAttribute('data-highlight-toggle'); + if (!Object.prototype.hasOwnProperty.call(state.highlightVisibility, key)) return; + state.highlightVisibility[key] = state.highlightVisibility[key] === false; + applyReadingDisplayState(); + saveReadingDisplayPreferences(); + }); + } else { + // If the group already exists without the nav toggle (hot reload / partial DOM), attach it. + const group = document.getElementById('reading-display-toggle-group'); + if (group && !document.getElementById('reading-question-nav-toggle')) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'reading-display-toggle'; + button.id = 'reading-question-nav-toggle'; + button.setAttribute('data-question-nav-toggle', ''); + button.title = '隐藏题卡'; + button.setAttribute('aria-pressed', 'true'); + button.textContent = 'Q'; + button.addEventListener('click', (event) => { + event.stopPropagation(); + state.questionNavCollapsed = !state.questionNavCollapsed; + applyReadingDisplayState(); + saveReadingDisplayPreferences(); + }); + group.appendChild(button); + } + } + applyReadingDisplayState(); + } + + function ensureReadingNoteStyles() { + if (document.getElementById(READING_NOTE_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = READING_NOTE_STYLE_ID; + style.textContent = ` + .hl[data-note-id]{position:relative;cursor:pointer;background:rgba(191,219,254,.78)!important;box-shadow:inset 0 -.52em rgba(147,197,253,.34)} + .hl[data-note-id].reading-note-flash{outline:2px solid #60a5fa;outline-offset:2px}.reading-notes-btn{position:relative} + .reading-note-count{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:99px;background:#16a34a;color:#fff;font-size:10px;line-height:16px;text-align:center;font-weight:700;display:none} + #reading-note-drawer{position:fixed;inset:0 0 0 auto;width:min(360px,92vw);background:#fff;border-left:1px solid #dbe4ef;box-shadow:-18px 0 36px rgba(15,23,42,.16);z-index:3600;transform:translateX(105%);transition:transform 180ms ease;display:flex;flex-direction:column} + #reading-note-drawer.open{transform:translateX(0)}.reading-note-drawer-head,.reading-note-editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid #e2e8f0} + .reading-note-drawer-title{display:flex;align-items:center;gap:8px}.reading-note-drawer-head h3,.reading-note-editor-head h3{margin:0;font-size:16px}.reading-note-list{padding:10px;overflow:auto;flex:1} + .reading-note-outline{border:1px solid #dbeafe;border-radius:8px;margin-bottom:10px;overflow:hidden;background:#f8fbff}.reading-note-outline-head{display:grid;grid-template-columns:30px 1fr 30px;align-items:center;padding:5px;background:#eff6ff}.reading-note-outline.collapsed .reading-note-outline-body{display:none} + .reading-note-outline-body,.reading-note-loose-list{min-height:26px;padding:4px 8px}.reading-note-row{display:grid;grid-template-columns:1fr 28px 30px;align-items:center;gap:4px;border-bottom:1px solid #edf2f7}.reading-note-row.dragging{opacity:.45}.reading-note-row.drag-over{box-shadow:inset 0 2px #2563eb} + .reading-note-open,.reading-note-outline-title{border:0;background:transparent;color:#0f172a;text-align:left;padding:9px 6px;border-radius:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}.reading-note-open:hover{background:#eff6ff;color:#1d4ed8} + .reading-note-close,.reading-note-delete,.reading-note-outline-toggle,.reading-note-outline-delete,.reading-note-drag-handle,.reading-note-outline-add{border:0;background:transparent;color:#64748b;cursor:pointer;width:30px;height:30px;border-radius:6px}.reading-note-outline-add{background:#eff6ff;color:#1d4ed8;font-size:18px}.reading-note-outline-title-input{min-width:0;border:1px solid #93c5fd;border-radius:5px;padding:6px} + #reading-note-editor{position:fixed;z-index:3700;width:min(620px,calc(100vw - 24px));height:min(520px,calc(100vh - 24px));min-width:320px;min-height:320px;background:#fff;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 22px 50px rgba(15,23,42,.22);display:none;flex-direction:column;overflow:hidden;resize:both} + .reading-note-editor-head{cursor:move;background:#f8fafc;user-select:none}.reading-note-editor-body{display:flex;flex-direction:column;gap:10px;padding:14px;flex:1;min-height:0}.reading-note-quote{margin:0;color:#475569;background:#eff6ff;border-left:3px solid #60a5fa;padding:8px 10px;max-height:74px;overflow:auto} + .reading-note-title,.reading-note-body{width:100%;border:1px solid #cbd5e1;border-radius:6px;padding:9px 10px;box-sizing:border-box}.reading-note-title{font-weight:700}.reading-note-body{min-height:190px;resize:vertical;flex:1} + body.dark-mode #reading-note-drawer,body.dark-mode #reading-note-editor{background:#1e293b;border-color:#475569;color:#e2e8f0}body.dark-mode .reading-note-open,body.dark-mode .reading-note-outline-title{color:#f8fafc} + @media(max-width:520px){#reading-note-editor{inset:12px!important;width:calc(100vw - 24px);height:calc(100vh - 24px);min-width:0;min-height:0;resize:none}} + `; + document.head.appendChild(style); + } + + function ensureReadingNotesButton() { + let button = document.getElementById('notes-drawer-btn'); + if (button) return button; + const headerRight = document.querySelector('.header-right'); + if (!headerRight) return null; + button = document.createElement('button'); + button.id = 'notes-drawer-btn'; + button.type = 'button'; + button.className = 'header-btn reading-notes-btn'; + button.title = 'Notes'; + button.innerHTML = 'Notes'; + headerRight.insertBefore(button, headerRight.firstChild); + button.addEventListener('click', (event) => { event.stopPropagation(); toggleNotesDrawer(); }); + return button; + } + + function ensureReadingNotesUi() { + ensureReadingNoteStyles(); + ensureReadingNotesButton(); + const legacyPanel = document.getElementById('notes-panel'); + const legacyButton = document.getElementById('note-btn'); + if (legacyPanel) { legacyPanel.style.display = 'none'; legacyPanel.setAttribute('aria-hidden', 'true'); } + if (legacyButton) { legacyButton.style.display = 'none'; legacyButton.setAttribute('aria-hidden', 'true'); } + let drawer = document.getElementById('reading-note-drawer'); + if (!drawer) { + drawer = document.createElement('aside'); + drawer.id = 'reading-note-drawer'; + drawer.setAttribute('aria-hidden', 'true'); + drawer.innerHTML = '

Notes

'; + document.body.appendChild(drawer); + drawer.addEventListener('click', handleNoteDrawerClick); + drawer.addEventListener('keydown', handleNoteDrawerKeydown); + drawer.addEventListener('focusout', handleNoteDrawerFocusOut); + drawer.addEventListener('dragstart', handleNoteDragStart); + drawer.addEventListener('dragover', handleNoteDragOver); + drawer.addEventListener('drop', handleNoteDrop); + drawer.addEventListener('dragend', clearNoteDragIndicators); + } + let editor = document.getElementById('reading-note-editor'); + if (!editor) { + editor = document.createElement('section'); + editor.id = 'reading-note-editor'; + editor.setAttribute('aria-hidden', 'true'); + editor.innerHTML = '

Note

'; + document.body.appendChild(editor); + editor.addEventListener('click', (event) => { if (event.target.closest?.('[data-note-editor-close]')) closeNoteEditor(); }); + editor.querySelector('[data-note-title]')?.addEventListener('input', saveActiveNoteFromEditor); + editor.querySelector('[data-note-body]')?.addEventListener('input', saveActiveNoteFromEditor); + editor.querySelector('[data-note-title]')?.addEventListener('change', flushActiveNoteFromEditor); + editor.querySelector('[data-note-body]')?.addEventListener('change', flushActiveNoteFromEditor); + attachNoteEditorDrag(editor); + } + if (!state.noteUiInitialized) { + state.noteUiInitialized = true; + document.addEventListener('click', handleNoteHighlightClick, true); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { closeNoteEditor(); closeNotesDrawer(); } + }); + } + syncNotesToLegacyText(); + renderNotesDrawer(); + refreshNoteHighlightAttributes(); + return drawer; + } + + function toggleNotesDrawer() { + const drawer = ensureReadingNotesUi(); + if (drawer?.classList.contains('open')) closeNotesDrawer(); + else openNotesDrawer(); + } + + function openNotesDrawer() { + const drawer = ensureReadingNotesUi(); + if (!drawer) return; + state.noteDrawerDirty = true; + drawer.classList.add('open'); + drawer.setAttribute('aria-hidden', 'false'); + renderNotesDrawer(); + } + + function closeNotesDrawer() { + const drawer = document.getElementById('reading-note-drawer'); + drawer?.classList.remove('open'); + drawer?.setAttribute('aria-hidden', 'true'); + } + + function renderNoteRow(note) { + const title = String(note.title || '').trim() || 'Untitled note'; + const editable = canEditReadingNotes(); + const disabled = editable ? '' : ' disabled'; + return `
`; + } + + function renderNotesDrawer() { + const count = state.notes.length; + const badge = document.querySelector('#notes-drawer-btn .reading-note-count'); + if (badge) { badge.textContent = String(count); badge.style.display = count ? 'block' : 'none'; } + const list = document.querySelector('#reading-note-drawer [data-note-list]'); + if (!list || !state.noteDrawerDirty) return; + const disabled = canEditReadingNotes() ? '' : ' disabled'; + const notesByOutline = new Map(); + sortNotesForDrawer().forEach((note) => { + const outlineId = getValidNoteOutlineId(note.outlineId); + const group = notesByOutline.get(outlineId) || []; + group.push(note); + notesByOutline.set(outlineId, group); + }); + const outlinesHtml = collectNoteOutlines().map((outline) => { + const notes = notesByOutline.get(outline.id) || []; + return `
${notes.map(renderNoteRow).join('')}
`; + }).join(''); + const loose = (notesByOutline.get('') || []).map(renderNoteRow).join(''); + list.innerHTML = count || state.noteOutlines.length + ? `${outlinesHtml}
${loose}
` + : '
No notes yet.
'; + const add = document.querySelector('#reading-note-drawer [data-note-outline-add]'); + if (add) add.disabled = !canEditReadingNotes(); + state.noteDrawerDirty = false; + } + + function handleNoteDrawerClick(event) { + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target) return; + if (target.closest('[data-note-drawer-close]')) return closeNotesDrawer(); + if (target.closest('[data-note-outline-add]')) return createNoteOutline(); + const toggle = target.closest('[data-note-outline-toggle]'); + if (toggle) return toggleNoteOutline(toggle.getAttribute('data-note-outline-toggle')); + const outlineDelete = target.closest('[data-note-outline-delete]'); + if (outlineDelete) return deleteNoteOutline(outlineDelete.getAttribute('data-note-outline-delete')); + const outlineTitle = target.closest('[data-note-outline-title]'); + if (outlineTitle) return startRenameNoteOutline(outlineTitle.getAttribute('data-note-outline-title')); + const noteDelete = target.closest('[data-note-delete]'); + if (noteDelete) return deleteNote(noteDelete.getAttribute('data-note-delete')); + const noteOpen = target.closest('[data-note-open]'); + if (noteOpen) { + const noteId = noteOpen.getAttribute('data-note-open'); + const anchor = findOrRestoreNoteHighlight(noteId); + if (anchor) scrollNoteHighlightIntoView(anchor); + openNoteEditor(noteId, { anchorNode: anchor }); + } + } + + function upsertNote(rawNote, options = {}) { + if (!canEditReadingNotes()) return null; + const normalized = normalizeNotes([rawNote])[0]; + if (!normalized) return null; + normalized.outlineId = getValidNoteOutlineId(normalized.outlineId); + const index = state.notes.findIndex((note) => note.id === normalized.id); + if (index >= 0) state.notes.splice(index, 1, { ...state.notes[index], ...normalized }); + else { + if (!Number.isFinite(Number(rawNote?.order))) normalized.order = getNextNoteOrder(normalized.outlineId); + state.notes.push(normalized); + } + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + syncNotesToLegacyText(); + if (options.forceUi !== false) { renderNotesDrawer(); refreshNoteHighlightAttributes(normalized.id); } + if (options.sync !== false) syncReadingAnnotation(options.reason || 'note'); + return getNoteById(normalized.id); + } + + function setNotes(rawNotes, rawOutlines = [], options = {}) { + const sanitized = sanitizeNotesWithOutlines(rawNotes, rawOutlines); + state.notes = sanitized.notes; + state.noteOutlines = sanitized.noteOutlines; + if (!state.notes.length && options.legacyText) { + const legacyText = String(options.legacyText || ''); + if (legacyText.trim()) { + state.notes = normalizeNotes([{ id: generateNoteId(), title: 'Notes', body: legacyText, quote: '' }]); + } + } + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + ensureReadingNotesUi(); + syncNotesToLegacyText(); + renderNotesDrawer(); + refreshNoteHighlightAttributes(); + restoreMissingNoteAnchors(); + } + + function createNoteOutline() { + if (!canEditReadingNotes()) return; + const now = Date.now(); + state.noteOutlines.push({ id: generateNoteOutlineId(), title: 'New outline', order: state.noteOutlines.length, collapsed: false, createdAt: now, updatedAt: now }); + state.noteDrawerDirty = true; + renderNotesDrawer(); + startRenameNoteOutline(state.noteOutlines[state.noteOutlines.length - 1].id); + syncReadingAnnotation('note-outline-add'); + } + + function getNoteOutlineById(id) { return state.noteOutlines.find((outline) => outline.id === String(id || '')) || null; } + + function toggleNoteOutline(id) { + if (!canEditReadingNotes()) return; + const outline = getNoteOutlineById(id); + if (!outline) return; + outline.collapsed = !outline.collapsed; + outline.updatedAt = Date.now(); + state.noteDrawerDirty = true; + renderNotesDrawer(); + syncReadingAnnotation('note-outline-toggle'); + } + + function deleteNoteOutline(id) { + if (!canEditReadingNotes()) return; + const outlineId = String(id || ''); + state.noteOutlines = state.noteOutlines.filter((outline) => outline.id !== outlineId); + state.notes.forEach((note) => { if (note.outlineId === outlineId) note.outlineId = ''; }); + state.noteDrawerDirty = true; + renderNotesDrawer(); + syncReadingAnnotation('note-outline-delete'); + } + + function startRenameNoteOutline(id) { + if (!canEditReadingNotes()) return; + const outline = getNoteOutlineById(id); + const button = document.querySelector(`[data-note-outline-title="${escapeSelector(id)}"]`); + if (!outline || !button) return; + const input = document.createElement('input'); + input.className = 'reading-note-outline-title-input'; + input.value = outline.title; + input.setAttribute('data-note-outline-title-input', outline.id); + button.replaceWith(input); + input.focus(); input.select(); + } + + function commitRenameNoteOutline(input, cancel = false) { + if (!(input instanceof HTMLInputElement) || input.dataset.committed === 'true') return; + if (!canEditReadingNotes() && !cancel) cancel = true; + input.dataset.committed = 'true'; + const outline = getNoteOutlineById(input.getAttribute('data-note-outline-title-input')); + if (outline && !cancel) { outline.title = String(input.value || '').trim() || 'New outline'; outline.updatedAt = Date.now(); } + state.noteDrawerDirty = true; + renderNotesDrawer(); + if (!cancel) syncReadingAnnotation('note-outline-rename'); + } + + function handleNoteDrawerKeydown(event) { + const input = event.target instanceof HTMLElement ? event.target.closest('[data-note-outline-title-input]') : null; + if (input) { + if (!canEditReadingNotes() && event.key !== 'Escape') return; + if (event.key === 'Enter') { event.preventDefault(); commitRenameNoteOutline(input); } + else if (event.key === 'Escape') { event.preventDefault(); commitRenameNoteOutline(input, true); } + return; + } + const handle = event.target instanceof HTMLElement ? event.target.closest('[data-note-drag-handle]') : null; + if (!handle || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return; + if (!canEditReadingNotes()) return; + event.preventDefault(); + const note = getNoteById(handle.getAttribute('data-note-drag-handle')); + if (!note) return; + if (event.key === 'ArrowLeft') note.outlineId = ''; + else if (event.key === 'ArrowRight' && state.noteOutlines[0]) note.outlineId = state.noteOutlines[0].id; + else { + const siblings = sortNotesForDrawer().filter((item) => item.outlineId === note.outlineId); + const index = siblings.findIndex((item) => item.id === note.id); + const targetIndex = event.key === 'ArrowUp' ? index - 1 : index + 1; + if (targetIndex >= 0 && targetIndex < siblings.length) { + const targetOrder = siblings[targetIndex].order; + siblings[targetIndex].order = note.order; + note.order = targetOrder; + } + } + note.updatedAt = Date.now(); + state.noteDrawerDirty = true; + renderNotesDrawer(); + syncReadingAnnotation('note-reorder'); + } + + function handleNoteDrawerFocusOut(event) { + const input = event.target instanceof HTMLInputElement ? event.target.closest('[data-note-outline-title-input]') : null; + if (input) commitRenameNoteOutline(input); + } + + let draggedNoteId = ''; + function handleNoteDragStart(event) { + if (!canEditReadingNotes()) return; + const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null; + if (!row) return; + draggedNoteId = row.getAttribute('data-note-row') || ''; + row.classList.add('dragging'); + event.dataTransfer?.setData('text/plain', draggedNoteId); + } + + function handleNoteDragOver(event) { + if (!canEditReadingNotes()) return; + const target = event.target instanceof HTMLElement ? event.target.closest('[data-note-row], [data-note-drop-list]') : null; + if (!target) return; + event.preventDefault(); + clearNoteDragIndicators(); + document.querySelector(`[data-note-row="${escapeSelector(draggedNoteId)}"]`)?.classList.add('dragging'); + target.classList.add('drag-over'); + } + + function handleNoteDrop(event) { + if (!canEditReadingNotes()) return clearNoteDragIndicators(); + event.preventDefault(); + const note = getNoteById(draggedNoteId || event.dataTransfer?.getData('text/plain')); + const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null; + const list = event.target instanceof HTMLElement ? event.target.closest('[data-note-drop-list]') : null; + if (!note || (!row && !list)) return clearNoteDragIndicators(); + const outlineId = getValidNoteOutlineId((list || row.closest('[data-note-drop-list]'))?.getAttribute('data-note-drop-list')); + const siblings = sortNotesForDrawer().filter((item) => item.id !== note.id && (item.outlineId || '') === outlineId); + const index = row ? Math.max(0, siblings.findIndex((item) => item.id === row.getAttribute('data-note-row'))) : siblings.length; + siblings.splice(index < 0 ? siblings.length : index, 0, note); + siblings.forEach((item, order) => { item.outlineId = outlineId; item.order = order; item.updatedAt = Date.now(); }); + state.noteDrawerDirty = true; + clearNoteDragIndicators(); + renderNotesDrawer(); + syncReadingAnnotation('note-reorder'); + } + + function clearNoteDragIndicators() { + document.querySelectorAll('.reading-note-row.dragging,.reading-note-row.drag-over,[data-note-drop-list].drag-over').forEach((node) => node.classList.remove('dragging', 'drag-over')); + draggedNoteId = ''; + } + + function clampNoteEditorPosition(left, top) { + const editor = document.getElementById('reading-note-editor'); + const margin = 12; + const width = editor?.offsetWidth || 430; + const height = editor?.offsetHeight || 330; + return { + left: Math.min(Math.max(margin, left), Math.max(margin, global.innerWidth - width - margin)), + top: Math.min(Math.max(margin, top), Math.max(margin, global.innerHeight - height - margin)) + }; + } + + function positionNoteEditor(anchorNode = null) { + const editor = document.getElementById('reading-note-editor'); + if (!editor) return; + let left = Number(state.noteEditorPosition?.left); + let top = Number(state.noteEditorPosition?.top); + if (!Number.isFinite(left) || !Number.isFinite(top)) { + const rect = anchorNode?.getBoundingClientRect?.(); + left = rect ? rect.left + Math.min(24, rect.width / 2) : (global.innerWidth - (editor.offsetWidth || 430)) / 2; + top = rect ? rect.bottom + 10 : (global.innerHeight - (editor.offsetHeight || 330)) / 2; + } + const position = clampNoteEditorPosition(left, top); + editor.style.left = `${Math.round(position.left)}px`; + editor.style.top = `${Math.round(position.top)}px`; + state.noteEditorPosition = position; + } + + function canEditReadingNotes() { + if (state.timerLocked) return false; + const activePracticeCanEdit = Boolean( + !state.readOnly + && !state.memorizeMode + && !state.submitted + ); + const submittedRecordCanEdit = Boolean( + state.submitted + && state.submittedRecordId + && !state.memorizeMode + ); + return Boolean(state.reviewMode || activePracticeCanEdit || submittedRecordCanEdit); + } + + function openNoteEditor(noteId, options = {}) { + ensureReadingNotesUi(); + if (state.activeNoteId && state.activeNoteId !== noteId) flushActiveNoteFromEditor(); + const note = getNoteById(noteId); + if (!note) return; + state.activeNoteId = note.id; + const editor = document.getElementById('reading-note-editor'); + const title = editor?.querySelector('[data-note-title]'); + const body = editor?.querySelector('[data-note-body]'); + const quote = editor?.querySelector('[data-note-quote]'); + if (!editor) return; + const canEditNotes = canEditReadingNotes(); + if (title) { title.value = note.title || ''; title.disabled = !canEditNotes; } + if (body) { body.value = note.body || ''; body.disabled = !canEditNotes; } + if (quote) { quote.textContent = note.quote || ''; quote.style.display = note.quote ? '' : 'none'; } + editor.style.display = 'flex'; + editor.setAttribute('aria-hidden', 'false'); + global.requestAnimationFrame(() => { + positionNoteEditor(options.anchorNode || findNoteHighlight(note.id)); + (options.focusBody ? body : title)?.focus(); + }); + } + + function closeNoteEditor() { + flushActiveNoteFromEditor(); + const editor = document.getElementById('reading-note-editor'); + if (editor) { editor.style.display = 'none'; editor.setAttribute('aria-hidden', 'true'); } + state.activeNoteId = ''; + } + + function attachNoteEditorDrag(editor) { + const handle = editor.querySelector('[data-note-drag-handle]'); + if (!handle) return; + let drag = null; + const move = (event) => { + if (!drag) return; + const next = clampNoteEditorPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y); + editor.style.left = `${Math.round(next.left)}px`; + editor.style.top = `${Math.round(next.top)}px`; + state.noteEditorPosition = next; + }; + const stop = () => { + drag = null; + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', stop); + document.removeEventListener('pointercancel', stop); + }; + handle.addEventListener('pointerdown', (event) => { + if (event.target.closest?.('button')) return; + const rect = editor.getBoundingClientRect(); + drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', stop); + document.addEventListener('pointercancel', stop); + event.preventDefault(); + }); + } + + function clearNoteEditorSaveTimer() { + if (state.noteEditorSaveTimer) global.clearTimeout(state.noteEditorSaveTimer); + state.noteEditorSaveTimer = null; + } + + function saveActiveNoteFromEditor() { + if (!canEditReadingNotes()) return; + const note = getNoteById(state.activeNoteId); + if (!note) return; + const editor = document.getElementById('reading-note-editor'); + const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim(); + const body = String(editor?.querySelector('[data-note-body]')?.value || ''); + if (title === note.title && body === note.body) return; + Object.assign(note, { title, body, updatedAt: Date.now() }); + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + state.noteEditorPendingSync = true; + syncNotesToLegacyText(); + clearNoteEditorSaveTimer(); + state.noteEditorSaveTimer = global.setTimeout(flushActiveNoteFromEditor, NOTE_EDITOR_SAVE_DEBOUNCE_MS); + } + + function flushActiveNoteFromEditor() { + if (!canEditReadingNotes()) return; + const note = getNoteById(state.activeNoteId); + if (!note) return; + const editor = document.getElementById('reading-note-editor'); + const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim(); + const body = String(editor?.querySelector('[data-note-body]')?.value || ''); + if (title === note.title && body === note.body && !state.noteEditorPendingSync) return; + clearNoteEditorSaveTimer(); + state.noteEditorPendingSync = false; + upsertNote({ ...note, title, body, updatedAt: Date.now() }, { forceUi: true, reason: 'note-edit' }); + } + + function createNoteAnchorSpan(note) { + const span = document.createElement('span'); + span.className = 'hl'; + span.dataset.hlType = 'note'; + span.dataset.noteId = note.id; + return span; + } + + function shouldSkipNoteAnchorTextNode(node) { + if (!node?.nodeValue?.trim()) return true; + const element = node.parentElement; + return Boolean(element?.closest?.('.hl') || getHighlightShared()?.isInsideExplanation?.(node)); + } + + function wrapNoteTextInRoot(root, note, quote) { + const nodes = getHighlightShared()?.getTextNodes?.(root) || []; + // 先统计整段里命中次数;saved highlight 缺失才会走到这条兜底路径,若同一引文 + // 多次出现,按“首次命中”绑定会静默定位到错误位置。这里要求全局唯一匹配才绑定, + // 否则放弃恢复该笔记的锚点,而不是盲目绑到第一个重复位置。 + let matchNode = null; + let matchIndex = -1; + let totalMatches = 0; + for (const node of nodes) { + if (shouldSkipNoteAnchorTextNode(node)) continue; + const value = String(node.nodeValue || ''); + let from = 0; + let idx = value.indexOf(quote, from); + while (idx >= 0) { + totalMatches += 1; + if (!matchNode) { + matchNode = node; + matchIndex = idx; + } + from = idx + quote.length; + idx = value.indexOf(quote, from); + } + } + if (totalMatches === 0 || totalMatches > 1 || !matchNode) { + return null; + } + const range = document.createRange(); + range.setStart(matchNode, matchIndex); range.setEnd(matchNode, matchIndex + quote.length); + const span = createNoteAnchorSpan(note); + try { range.surroundContents(span); return span; } catch (_) { return null; } + } + + function findRestorableNoteAnchor(note) { + const quote = normalizeNoteText(note?.quote); + if (!quote || quote.length < 2) return null; + // 唯一性的判定需要在整篇 passage 范围内完成;逐 root 绑定会让跨 root + // 的重复引文被误判为“当前 root 内唯一”。先聚合所有命中,再决定绑定。 + const roots = [dom.left, dom.groups].filter(Boolean); + let totalMatches = 0; + let matchRoot = null; + for (const root of roots) { + const nodes = getHighlightShared()?.getTextNodes?.(root) || []; + for (const node of nodes) { + if (shouldSkipNoteAnchorTextNode(node)) continue; + const value = String(node.nodeValue || ''); + let from = 0; + let idx = value.indexOf(quote, from); + while (idx >= 0) { + totalMatches += 1; + if (!matchRoot) matchRoot = root; + from = idx + quote.length; + idx = value.indexOf(quote, from); + } + } + } + if (totalMatches !== 1 || !matchRoot) return null; + return wrapNoteTextInRoot(matchRoot, note, quote); + } + + function restoreMissingNoteAnchors() { + let count = 0; + state.notes.forEach((note) => { + if (!findNoteHighlight(note.id) && findRestorableNoteAnchor(note)) count += 1; + }); + if (count) { state.noteHighlightMetaDirty = true; refreshNoteHighlightAttributes(); } + return count; + } + + function ensureNoteForHighlight(highlightNode, quote = '', options = {}) { + if (!(highlightNode instanceof HTMLElement)) return null; + let note = getNoteById(highlightNode.dataset.noteId); + if (!note && !canEditReadingNotes()) return null; + if (!note) { + const now = Date.now(); + note = upsertNote({ + id: highlightNode.dataset.noteId || generateNoteId(), + title: '', body: '', quote: quote || normalizeNoteText(highlightNode.textContent), + createdAt: now, updatedAt: now + }, { sync: false }); + } + if (note) { + highlightNode.dataset.noteId = note.id; + highlightNode.dataset.hlType = 'note'; + state.noteHighlightMetaDirty = true; + refreshNoteHighlightAttributes(note.id); + if (options.sync !== false) syncReadingAnnotation('note-anchor'); + } + return note; + } + + function ensureNoteAnchorsBeforeSnapshot() { + document.querySelectorAll('.hl[data-hl-type="note"]').forEach((node) => { + if (node instanceof HTMLElement && !node.dataset.noteId) { + ensureNoteForHighlight(node, normalizeNoteText(node.textContent), { sync: false }); + } + }); + } + + function findNoteHighlight(noteId) { + const id = String(noteId || '').trim(); + return id ? document.querySelector(`.hl[data-note-id="${escapeSelector(id)}"]`) : null; + } + + function findOrRestoreNoteHighlight(noteId) { + const existing = findNoteHighlight(noteId); + if (existing) return existing; + const note = getNoteById(noteId); + return note ? findRestorableNoteAnchor(note) : null; + } + + function scrollNoteHighlightIntoView(node) { + node?.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + node?.classList.add('reading-note-flash'); + global.setTimeout(() => node?.classList.remove('reading-note-flash'), 900); + } + + function deleteNote(noteId, options = {}) { + if (!canEditReadingNotes()) return; + const id = String(noteId || '').trim(); + if (!id) return; + state.notes = state.notes.filter((note) => note.id !== id); + document.querySelectorAll(`.hl[data-note-id="${escapeSelector(id)}"]`).forEach((node) => { + const parent = node.parentNode; + if (!parent) return; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + node.remove(); parent.normalize(); + }); + if (state.activeNoteId === id) { state.activeNoteId = ''; closeNoteEditor(); } + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + syncNotesToLegacyText(); + renderNotesDrawer(); + if (options.sync !== false) syncReadingAnnotation('note-delete'); + } + + function clearStructuredNotesForReset() { + if (!canEditReadingNotes()) return; + clearNoteEditorSaveTimer(); + state.noteEditorPendingSync = false; + state.activeNoteId = ''; + state.notes = []; + state.noteOutlines = []; + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + document.querySelectorAll('.hl[data-note-id], .hl[data-hl-type="note"]').forEach((node) => { + const parent = node.parentNode; + if (!parent) return; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + node.remove(); + parent.normalize(); + }); + setNotesText(''); + const editor = document.getElementById('reading-note-editor'); + if (editor) { + editor.querySelectorAll('input, textarea').forEach((field) => { field.value = ''; }); + editor.style.display = 'none'; + editor.setAttribute('aria-hidden', 'true'); + } + closeNotesDrawer(); + renderNotesDrawer(); + } + + function refreshNoteHighlightAttributes(noteId = '') { + if (!state.noteHighlightMetaDirty && !noteId) return; + const selector = noteId ? `.hl[data-note-id="${escapeSelector(noteId)}"]` : '.hl[data-note-id]'; + document.querySelectorAll(selector).forEach((node) => { + if (!(node instanceof HTMLElement)) return; + const note = getNoteById(node.dataset.noteId); + const title = String(note?.title || '').trim() || buildDefaultNoteTitle(node.textContent); + node.dataset.hlType = 'note'; + node.title = `Note: ${title}`; + node.setAttribute('role', 'button'); + node.tabIndex = 0; + node.setAttribute('aria-label', `Open note: ${title}`); + }); + state.noteHighlightMetaDirty = false; + } + + function handleNoteHighlightClick(event) { + const highlight = event.target instanceof HTMLElement ? event.target.closest('.hl[data-note-id]') : null; + if (!highlight) return; + event.preventDefault(); event.stopPropagation(); + openNoteEditor(highlight.dataset.noteId, { anchorNode: highlight }); + } + + function syncReadingAnnotation(reason = 'note') { + if (!canEditReadingNotes()) return; + const isSuiteReviewAnnotation = Boolean( + state.simulationMode + && state.suiteReviewMode + && state.reviewMode + && state.suiteSessionId + ); + if (state.simulationMode && (!state.readOnly || isSuiteReviewAnnotation)) { + syncSimulationDraftSnapshot(reason); + return; + } + if (state.reviewMode) { + postMessage('READING_ANNOTATION_SYNC', { + examId: state.examId, + recordId: state.reviewRecordId || null, + reviewSessionId: state.reviewSessionId || null, + sessionId: state.sessionId || null, + windowSessionToken: state.windowSessionToken || null, + annotations: { + highlights: collectHighlights(), + noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), + scrollY: global.scrollY || 0 + }, + reason + }); + return; + } + // 单篇 final-submit 后(submitted=true,reviewMode=false),宿主在保存练习 + // 记录后通过 PRACTICE_RECORD_SAVED 回传 recordId。持有该 id 时,结果页笔记 + // 改动需要以 READING_ANNOTATION_SYNC 直接写回已存档的练习记录,而非走草稿 + // 同步(草稿在提交时已被清除,且 draft 分支在此状态下会被跳过)。 + if (state.submitted && state.submittedRecordId && !state.memorizeMode) { + postMessage('READING_ANNOTATION_SYNC', { + examId: state.examId, + recordId: state.submittedRecordId, + reviewSessionId: null, + sessionId: state.sessionId || null, + windowSessionToken: state.windowSessionToken || null, + annotations: { + highlights: collectHighlights(), + noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), + scrollY: global.scrollY || 0 + }, + reason + }); + return; + } + if (!state.readOnly && !state.submitted && !state.memorizeMode) { + syncReadingDraftSnapshot(reason); + } + } + async function ensureExplanationDataset() { const registry = global.__READING_EXPLANATION_DATA__; if (!registry || typeof registry.get !== 'function') { @@ -1469,6 +2571,11 @@ .reading-locator-highlight:hover { background: rgba(250, 204, 21, 0.62); } + .reading-locator-overlap { cursor:pointer; text-decoration:underline #dc2626 2px; text-underline-offset:3px; } + .reading-locator-highlight.is-review-jump-target,.reading-locator-overlap.is-review-jump-target { outline:2px solid rgba(37,99,235,.45); outline-offset:2px; } + .reading-locator-block { display:inline-block;width:1px;height:1em;overflow:hidden;opacity:0;pointer-events:none;vertical-align:baseline; } + .reading-passage-locator-target.is-review-jump-target { border-radius:4px;outline:2px solid rgba(37,99,235,.38);background:rgba(96,165,250,.12); } + .results-table .question-jump-btn { border:0;padding:0;background:transparent;color:#2563eb;font:inherit;font-weight:700;cursor:pointer;text-decoration:underline;text-underline-offset:2px; } `; document.head.appendChild(style); } @@ -1487,6 +2594,11 @@ return; } shared.unwrapMatchingHighlights(dom.left, LOCATOR_HIGHLIGHT_SELECTOR); + dom.left?.querySelectorAll('.reading-passage-locator-target').forEach((node) => node.classList.remove('reading-passage-locator-target', 'is-review-jump-target')); + dom.left?.querySelectorAll(LOCATOR_OVERLAP_SELECTOR).forEach((node) => { + node.classList.remove('reading-locator-overlap', 'is-review-jump-target'); + delete node.dataset.locatorOverlap; + }); } function getHighlightShared() { @@ -1789,17 +2901,14 @@ let draftsByExam = {}; let resultsByExam = {}; try { - const raw = global.sessionStorage?.getItem('ielts_sim_session'); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed) { - if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence; - if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam; - if (Array.isArray(parsed.results)) { - parsed.results.forEach(res => { - if (res && res.examId) resultsByExam[res.examId] = res; - }); - } + const parsed = global.AppData?.recovery?.windowSession?.get('simulation'); + if (parsed) { + if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence; + if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam; + if (Array.isArray(parsed.results)) { + parsed.results.forEach(res => { + if (res && res.examId) resultsByExam[res.examId] = res; + }); } } } catch (_) {} @@ -2494,7 +3603,7 @@ function attachMemorizeLocatorListeners() { document.addEventListener('click', (event) => { const target = event.target instanceof HTMLElement - ? event.target.closest('.reading-locator-highlight[data-question-id]') + ? event.target.closest('.reading-locator-highlight[data-question-id],.reading-locator-overlap[data-question-id],.reading-locator-block[data-question-id]') : null; if (!target) { return; @@ -2788,6 +3897,61 @@ return snippets; } + function buildLocatorSnippetVariants(text) { + const source = String(text || '').replace(/\s+/g, ' ').trim(); + if (!source) return []; + return Array.from(new Set([ + source, + source.replace(/[‘’]/g, "'").replace(/[“”]/g, '"'), + source.replace(/[‐‑‒–—―]/g, '-'), + source.replace(/\s+-\s+/g, ' — '), + source.replace(/\s+-\s+/g, ' – ') + ])).filter(Boolean); + } + + function normalizeLocatorComparableText(text) { + return String(text || '').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/[‐‑‒–—―]/g, '-').replace(/\s+/g, ' ').trim().toLowerCase(); + } + + function findPassageBlockForLocatorSnippet(snippet) { + if (!dom.left || !snippet) return null; + const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText); + return Array.from(dom.left.querySelectorAll('p, li, td, th, div')).filter((node) => { + if (node.closest(EXPLANATION_NODE_SELECTOR) || node.classList.contains('reading-locator-highlight')) return false; + if (node.tagName === 'DIV' && node.querySelector('p, li, td, th')) return false; + const text = normalizeLocatorComparableText(node.textContent); + return text.length >= 10 && variants.some((variant) => text.includes(variant)); + }).sort((a, b) => String(a.textContent || '').length - String(b.textContent || '').length)[0] || null; + } + + function markOverlappingLocatorHighlight(questionId, snippet) { + const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText); + const target = Array.from(dom.left?.querySelectorAll('.hl') || []).find((node) => { + const text = normalizeLocatorComparableText(node.textContent); + return text.length >= 12 && variants.some((variant) => text.includes(variant) || variant.includes(text)); + }); + if (!target) return null; + target.classList.add('reading-locator-overlap'); + target.dataset.questionId = questionId; + target.dataset.locatorOverlap = 'true'; + target.title = `Q${displayLabel(questionId)} 定位`; + return target; + } + + function createLocatorBlock(questionId, snippet) { + const target = findPassageBlockForLocatorSnippet(snippet); + if (!target) return null; + const existing = target.querySelector(`.reading-locator-block[data-question-id="${escapeSelector(questionId)}"]`); + if (existing) return existing; + target.classList.add('reading-passage-locator-target'); + const marker = document.createElement('span'); + marker.className = 'reading-locator-block'; + marker.dataset.questionId = questionId; + marker.setAttribute('aria-hidden', 'true'); + target.insertBefore(marker, target.firstChild); + return marker; + } + function buildMemorizeLocatorSnippets() { const snippetsByQuestionId = new Map(); const sections = Array.isArray(state.explanation?.questionExplanations) @@ -2831,7 +3995,7 @@ function applyMemorizeLocatorHighlights() { clearMemorizeLocatorHighlights(); - if (!state.memorizeMode || !dom.left) { + if ((!state.memorizeMode && !state.reviewMode && !state.submitted) || !dom.left) { return 0; } const shared = getHighlightShared(); @@ -2843,21 +4007,68 @@ let applied = 0; snippetsByQuestionId.forEach((snippets, questionId) => { snippets.slice(0, 4).forEach((snippet) => { - const matches = shared.wrapTextMatches(dom.left, snippet, { - className: 'reading-locator-highlight', - attrs: { - 'data-question-id': questionId, - title: `Q${displayLabel(questionId)} 定位` - }, - limit: 2, - skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block' - }); + let matches = []; + for (const variant of buildLocatorSnippetVariants(snippet)) { + if (matches.length) break; + matches = shared.wrapTextMatches(dom.left, variant, { + className: 'reading-locator-highlight', + attrs: { 'data-question-id': questionId, title: `Q${displayLabel(questionId)} 定位` }, + limit: 2, + skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block' + }); + } + if (!matches.length) { + const overlap = markOverlappingLocatorHighlight(questionId, snippet); + if (overlap) matches = [overlap]; + } + if (!matches.length) { + const marker = createLocatorBlock(questionId, snippet); + if (marker) matches = [marker]; + } applied += matches.length; }); }); return applied; } + function findLocatorAnchor(questionId) { + const normalized = normalizeQuestionId(questionId); + return Array.from(document.querySelectorAll('.reading-locator-highlight[data-question-id],.reading-locator-block[data-question-id],.reading-locator-overlap[data-question-id]')) + .find((node) => normalizeQuestionId(node.dataset.questionId) === normalized) || null; + } + + function applyLocatorHighlightsForQuestion(questionId) { + const normalized = normalizeQuestionId(questionId); + const snippets = buildMemorizeLocatorSnippets().get(normalized) || []; + if (!normalized || !dom.left) return 0; + const shared = getHighlightShared(); + for (const snippet of snippets) { + for (const variant of buildLocatorSnippetVariants(snippet)) { + const matches = shared?.wrapTextMatches?.(dom.left, variant, { + className: 'reading-locator-highlight', + attrs: { 'data-question-id': normalized, title: `Q${displayLabel(normalized)} 定位` }, + limit: 1, + skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block' + }) || []; + if (matches.length) return matches.length; + } + if (markOverlappingLocatorHighlight(normalized, snippet) || createLocatorBlock(normalized, snippet)) return 1; + } + return 0; + } + + function jumpToQuestionEvidence(questionId) { + if (!findLocatorAnchor(questionId)) applyLocatorHighlightsForQuestion(questionId); + const locator = findLocatorAnchor(questionId); + const target = locator || findQuestionAnchor(questionId); + if (!target) return false; + target.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); + const highlightTarget = locator?.classList.contains('reading-locator-block') ? locator.closest('.reading-passage-locator-target') : locator; + highlightTarget?.classList.add('is-review-jump-target'); + global.setTimeout(() => highlightTarget?.classList.remove('is-review-jump-target'), 1800); + return true; + } + async function renderMemorizeStudyLayer() { if (!state.memorizeMode) { return; @@ -2926,7 +4137,7 @@ if (!item) return null; const sourceDropzone = item.closest('.paragraph-dropzone, .match-dropzone, .drop-target-summary'); return { - value: item.dataset.heading || item.dataset.option || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(), + value: item.dataset.heading || item.dataset.option || item.dataset.key || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(), label: item.dataset.answerLabel || item.dataset.word || item.dataset.value || item.textContent.trim(), sourceDropzoneId: sourceDropzone?.dataset?.dropzoneId || '' }; @@ -3637,7 +4848,11 @@ const expectedToken = canonicalizeAnswerToken(answerKey[normalizedTargetId]); const assignedToken = assignments.get(normalizedTargetId) || ''; return { - displayUserAnswer: assignedToken || answers[normalizedTargetId] || '', + // Review rows for split-key multi-choice still show the full selected set + // so partial credit remains inspectable even though scoring is per expected token. + displayUserAnswer: selectedTokens.length + ? selectedTokens.slice() + : (assignedToken || answers[normalizedTargetId] || ''), expectedToken, isCorrect: Boolean(assignedToken && expectedToken && areAnswerTokensEquivalent(assignedToken, expectedToken)) }; @@ -3801,7 +5016,7 @@ const statusClass = entry.isCorrect ? 'result-correct' : (isPartial ? 'result-partial' : 'result-incorrect'); return ` - ${label} + ${userAnswer} ${correctAnswer || ''} ${status} @@ -3824,6 +5039,9 @@ `; dom.results.style.display = 'block'; + dom.results.querySelectorAll?.('[data-result-question-id]').forEach((button) => { + button.addEventListener('click', () => jumpToQuestionEvidence(button.dataset.resultQuestionId || '')); + }); } function escapeSelector(value) { @@ -4008,9 +5226,21 @@ const controls = document.querySelectorAll('input, textarea, select'); controls.forEach((control) => { if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement) { + // review、普通进行中练习、以及已回传 recordId 的结果页允许编辑笔记; + // 只读/计时锁定/背诵模式仍保持禁用,避免改动无法保存或破坏答题流程。 + const canEditNotes = canEditReadingNotes(); + if ( + canEditNotes + && typeof control.closest === 'function' + && control.closest('#reading-note-editor, #reading-note-drawer') + ) { + control.disabled = false; + return; + } control.disabled = state.readOnly || state.timerLocked; } }); + renderNotesDrawer(); syncPrimaryActionButtons(); refreshSimulationDraftSyncLifecycle(); enhanceReviewHighlights(); @@ -4031,6 +5261,8 @@ } function enterSubmittedReadOnlyState(reason = 'submit') { + clearSubmissionAckTimer(); + state.submissionStatus = 'submitted'; state.submitted = true; setReadOnlyMode(true, reason); disableDragInteractions(); @@ -4043,22 +5275,136 @@ syncPrimaryActionButtons(); } + function clearSubmissionAckTimer() { + if (state.submissionAckTimer) { + clearTimeout(state.submissionAckTimer); + state.submissionAckTimer = null; + } + } + + function createSubmissionId() { + try { + if (global.crypto && typeof global.crypto.randomUUID === 'function') { + return global.crypto.randomUUID(); + } + } catch (_) { + // Fall through to a session-bound identifier. + } + return [state.sessionId || 'session', state.examId || 'exam', Date.now(), Math.random().toString(36).slice(2)].join(':'); + } + + function restoreDraftSubmissionState(submissionId = '') { + if (state.submissionStatus === 'submitted') { + return false; + } + if (submissionId && state.submissionId && submissionId !== state.submissionId) { + return false; + } + clearSubmissionAckTimer(); + state.submissionStatus = 'draft'; + state.submitted = false; + syncPrimaryActionButtons(); + return true; + } + + function expirePendingSubmission(submissionId = '') { + if (state.submissionStatus !== 'submitting') { + return false; + } + return restoreDraftSubmissionState(submissionId || state.submissionId); + } + + function beginSubmission(messageType, payload, presentation = null) { + if (state.submissionStatus === 'submitting' || state.submissionStatus === 'submitted') { + return false; + } + if (!state.submissionId) { + state.submissionId = createSubmissionId(); + } + state.submissionStatus = 'submitting'; + state.pendingSubmissionPresentation = presentation; + syncPrimaryActionButtons(); + const delivered = postMessage(messageType, Object.assign({}, payload || {}, { + submissionId: state.submissionId + })); + if (!delivered) { + restoreDraftSubmissionState(state.submissionId); + return false; + } + clearSubmissionAckTimer(); + state.submissionAckTimer = setTimeout(() => { + expirePendingSubmission(state.submissionId); + }, SUBMIT_ACK_TIMEOUT_MS); + return true; + } + + function matchesPendingSubmission(data = {}) { + if (state.submissionStatus !== 'submitting') return false; + const submissionId = data && data.submissionId != null ? String(data.submissionId).trim() : ''; + const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : ''; + const examId = data && data.examId != null ? String(data.examId).trim() : ''; + const suiteSessionId = data && data.suiteSessionId != null ? String(data.suiteSessionId).trim() : ''; + if (!submissionId || submissionId !== state.submissionId) return false; + if (!sessionId || !state.sessionId || sessionId !== String(state.sessionId)) return false; + if (!examId || !state.examId || examId !== String(state.examId)) return false; + if (state.suiteSessionId && suiteSessionId !== String(state.suiteSessionId)) return false; + if (!state.suiteSessionId && suiteSessionId) return false; + return true; + } + + async function acceptSubmissionAcknowledgement(data = {}) { + if (!matchesPendingSubmission(data)) { + return false; + } + const presentation = state.pendingSubmissionPresentation; + clearSubmissionAckTimer(); + enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit'); + if (presentation && presentation.results) { + state.lastResults = presentation.results; + renderResults(presentation.results); + await renderExplanations(); + applyHighlights(Array.isArray(presentation.highlights) ? presentation.highlights : []); + refreshNoteHighlightAttributes(); + restoreMissingNoteAnchors(); + applyMemorizeLocatorHighlights(); + enhanceReviewHighlights(); + updateNavStatuses(presentation.results); + } + state.pendingSubmissionPresentation = null; + if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) { + stopSimulationDraftSync(); + clearSimulationDraftMirror(); + state.simulationDraftFingerprint = ''; + } + return true; + } + if (global.__IELTS_READING_PAGE_TEST_HOOKS__ === true) { global.__IELTS_UNIFIED_READING_PAGE_TEST__ = Object.assign( global.__IELTS_UNIFIED_READING_PAGE_TEST__ || {}, { buildReplayResults, mergeDraft, + normalizeNotes, + normalizeNoteOutlines, + syncReadingAnnotation, mergeSuiteDraftPayload, captureInlineSuiteDraftBeforeReinit, shouldIgnoreInlineSuiteEnvelope, shouldAcceptWindowSessionMessage, adoptWindowSessionMessage, + buildInitSignature, handleIncoming, initializeInlineSimulationSuite, buildResultsFromAnswers, renderTimer, handleSubmit, + beginSubmission, + acceptSubmissionAcknowledgement, + expirePendingSubmission, + restoreDraftSubmissionState, + stopReadingDraftSync, + stopSimulationDraftSync, getTestState() { return { examId: state.examId, @@ -4078,6 +5424,19 @@ currentIndex: state.suite?.currentIndex || 0, suiteInline: Boolean(state.suite?.inline), suiteTimerLimitSeconds: state.suiteTimerLimitSeconds, + reviewRecordId: state.reviewRecordId, + submittedRecordId: state.submittedRecordId, + submitted: state.submitted, + readOnly: state.readOnly, + submissionStatus: state.submissionStatus, + submissionId: state.submissionId, + parentOrigin: state.parentOrigin, + parentOriginIsOpaque: state.parentOriginIsOpaque, + expectedParentOrigin: state.expectedParentOrigin, + windowSessionToken: state.windowSessionToken, + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: normalizeMarkedQuestions(state.markedQuestions), suiteSequence: Array.isArray(state.suite?.sequence) ? state.suite.sequence.map((entry) => ({ ...entry })) : [], @@ -4204,7 +5563,7 @@ if (!state.readOnly || canResetSubmittedSingle) { setSubmitLabel(dom.submitBtn.dataset.defaultLabel || 'Submit'); } - dom.submitBtn.disabled = state.readOnly; + dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting'; } if (dom.resetBtn) { dom.resetBtn.style.display = ''; @@ -4225,7 +5584,7 @@ dom.submitBtn.style.display = ctx.isLast ? '' : 'none'; dom.submitBtn.setAttribute('type', 'button'); setSubmitLabel('Submit'); - dom.submitBtn.disabled = state.readOnly; + dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting'; } } @@ -4298,8 +5657,13 @@ } function resetToAnsweringPresentation() { + clearSubmissionAckTimer(); state.lastResults = null; state.submitted = false; + state.submissionStatus = 'draft'; + state.submissionId = ''; + state.pendingSubmissionPresentation = null; + state.submittedRecordId = ''; state.readOnly = false; state.timerLocked = false; state.timerExpired = false; @@ -4361,6 +5725,8 @@ syncPrimaryActionButtons(); } else { state.reviewMode = true; + // 进入 review 视图后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。 + state.submittedRecordId = ''; if (data.readOnly !== false) { enterSubmittedReadOnlyState('stationary-review'); } else { @@ -4371,6 +5737,7 @@ async function applyReplayRecord(data = {}) { const entry = data.entry && typeof data.entry === 'object' ? data.entry : data; + const replayData = entry.realData && typeof entry.realData === 'object' ? entry.realData : {}; const entryExamId = entry && entry.examId != null ? String(entry.examId).trim() : ''; const currentExamId = state.examId != null ? String(state.examId).trim() : ''; if (entryExamId && currentExamId && entryExamId !== currentExamId) { @@ -4383,7 +5750,10 @@ ? entry.markedQuestions : (Array.isArray(entry.metadata && entry.metadata.markedQuestions) ? entry.metadata.markedQuestions - : [])); + : (Array.isArray(replayData.markedQuestions) ? replayData.markedQuestions : []))); + state.reviewRecordId = String(data.recordId || entry.id || '').trim(); + // 进入 review 回放后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。 + state.submittedRecordId = ''; if (data.reviewSessionId) { state.reviewSessionId = data.reviewSessionId; } @@ -4393,8 +5763,16 @@ state.reviewMode = true; state.reviewViewMode = 'review'; applyReplayAnswersToDom(replayResults.answers || {}); - const replayHighlights = Array.isArray(entry.highlights) ? entry.highlights : []; + const replayHighlights = Array.isArray(entry.highlights) + ? entry.highlights + : (Array.isArray(replayData.highlights) ? replayData.highlights : []); applyHighlights(replayHighlights); + setNotes( + Array.isArray(entry.notes) ? entry.notes : replayData.notes, + Array.isArray(entry.noteOutlines) ? entry.noteOutlines : replayData.noteOutlines, + { legacyText: typeof entry.noteText === 'string' ? entry.noteText : replayData.noteText } + ); + state.markedQuestions = normalizeMarkedQuestions(replayMarks); enhanceReviewHighlights(); if (Number.isFinite(Number(entry.scrollY))) { global.scrollTo(0, Number(entry.scrollY)); @@ -4403,6 +5781,9 @@ renderResults(replayResults); await renderExplanations(); applyHighlights(replayHighlights); + refreshNoteHighlightAttributes(); + restoreMissingNoteAnchors(); + applyMemorizeLocatorHighlights(); enhanceReviewHighlights(); updateNavStatuses(replayResults); if (data.readOnly !== false) { @@ -4501,9 +5882,6 @@ function adoptWindowSessionMessage(data = {}, sourceWindow = null) { const incomingToken = normalizeWindowSessionToken(data && data.windowSessionToken); const incomingIssuedAtMs = readMessageIssuedAtMs(data); - if (sourceWindow) { - state.parentWindow = sourceWindow; - } if (incomingToken) { state.windowSessionToken = incomingToken; } @@ -4514,25 +5892,77 @@ } } - function postMessage(type, payload) { - const envelope = buildEnvelope(type, payload); - const candidates = [global.opener, state.parentWindow, global.parent]; - const visited = new Set(); - for (let index = 0; index < candidates.length; index += 1) { - const target = candidates[index]; - if (!target || target === global || visited.has(target)) { - continue; + function acceptHostInitMessage(event, envelope, data = {}) { + if (!state.parentWindow || !event || event.source !== state.parentWindow) return false; + if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false; + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + const declaredOrigin = typeof data.parentOrigin === 'string' ? data.parentOrigin : ''; + const incomingToken = normalizeWindowSessionToken(data.windowSessionToken); + if (!incomingToken) return false; + // "file://" is not a usable postMessage target/origin pin. Treat it the same + // as an unbound referrer so file:// hosts can bind via opaque "null". + const expectedParentOrigin = state.expectedParentOrigin + && state.expectedParentOrigin !== 'file://' + && !String(state.expectedParentOrigin).startsWith('file:') + ? state.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) { + return false; } - visited.add(target); - try { - target.postMessage(envelope, '*'); - state.parentWindow = target; - return true; - } catch (_) { - // try next candidate + state.parentOrigin = expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else if (global.location.protocol === 'file:') { + // File pages can report either opaque "null" or "file://" for iframe + // messages across Chromium platforms; never accept a web origin here. + const trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://') + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) { + return false; + } + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } else { + const trustedWebOrigin = Boolean(incomingOrigin) + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) { + return false; } + state.parentOrigin = incomingOrigin; + state.parentOriginIsOpaque = false; + } + return true; + } + + function isTrustedHostMessage(event, envelope, data = {}) { + if (!state.parentWindow || !event || event.source !== state.parentWindow) return false; + if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false; + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + if (state.parentOriginIsOpaque) { + if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') return false; + } else if (!state.parentOrigin || incomingOrigin !== state.parentOrigin) { + return false; + } + const expectedToken = normalizeWindowSessionToken(state.windowSessionToken); + const incomingToken = normalizeWindowSessionToken(data.windowSessionToken); + return Boolean(expectedToken && incomingToken && expectedToken === incomingToken); + } + + function postMessage(type, payload) { + const envelope = buildEnvelope(type, payload); + const target = state.parentWindow; + if (!target || target === global || typeof target.postMessage !== 'function') return false; + const targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) return false; + try { + return target.postMessage(envelope, targetOrigin) !== false; + } catch (_) { + return false; } - return false; } function stopInitLoop() { @@ -4574,7 +6004,8 @@ suiteTimerAnchorMs: Number.isFinite(Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs))) ? Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs)) : null, suiteTimerMode: data && typeof data.suiteTimerMode === 'string' ? data.suiteTimerMode.trim().toLowerCase() : '', suiteTimerLimitSeconds: parseOptionalNonNegativeInteger(data && data.suiteTimerLimitSeconds), - globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null + globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null, + draftFingerprint: buildDraftFingerprint(data && data.draft) }); } @@ -4600,6 +6031,10 @@ } function restartInitHandshake() { + clearSubmissionAckTimer(); + state.submissionStatus = 'draft'; + state.submissionId = ''; + state.pendingSubmissionPresentation = null; state.sessionId = null; state.sessionReadySent = false; state.lastInitSignature = ''; @@ -4651,13 +6086,13 @@ }, 500); } - function getSimulationDraftStorageKey() { + function getSimulationDraftSessionName() { const suiteSessionId = state.suiteSessionId ? String(state.suiteSessionId).trim() : ''; const examId = state.examId ? String(state.examId).trim() : ''; if (!suiteSessionId || !examId) { return ''; } - return `ielts_sim_draft::${suiteSessionId}::${examId}`; + return `simulation-draft:${suiteSessionId}:${examId}`; } function cloneDraftSafely(draft) { @@ -4671,6 +6106,9 @@ answers: draft.answers && typeof draft.answers === 'object' ? { ...draft.answers } : {}, highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [], noteText: typeof draft.noteText === 'string' ? draft.noteText : '', + notes: normalizeNotes(draft.notes), + noteOutlines: normalizeNoteOutlines(draft.noteOutlines), + markedQuestions: normalizeMarkedQuestions(draft.markedQuestions), scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0 }; } @@ -4681,6 +6119,11 @@ return ''; } try { + // updatedAt 每次调用都会刷新(Date.now()),若纳入指纹会让周期性比对永远不相等, + // 导致空闲时每 1.5s 都会重复 POST/持久化草稿。只用稳定内容计算指纹。 + if ('updatedAt' in draft) { + return JSON.stringify(Object.assign({}, draft, { updatedAt: null })); + } return JSON.stringify(draft); } catch (_) { return ''; @@ -4688,29 +6131,27 @@ } function persistSimulationDraftMirror(draft) { - const key = getSimulationDraftStorageKey(); - if (!key || !global.sessionStorage || !draft) { + const name = getSimulationDraftSessionName(); + if (!name || !global.AppData?.recovery?.windowSession || !draft) { return; } try { - global.sessionStorage.setItem(key, JSON.stringify({ + global.AppData.recovery.windowSession.save(name, { draft, updatedAt: Date.now() - })); + }); } catch (_) { // ignore sessionStorage failures in restricted environments } } function restoreSimulationDraftMirror() { - const key = getSimulationDraftStorageKey(); - if (!key || !global.sessionStorage) { + const name = getSimulationDraftSessionName(); + if (!name || !global.AppData?.recovery?.windowSession) { return null; } try { - const raw = global.sessionStorage.getItem(key); - if (!raw) return null; - const parsed = JSON.parse(raw); + const parsed = global.AppData.recovery.windowSession.get(name); if (!parsed || typeof parsed !== 'object') { return null; } @@ -4723,12 +6164,12 @@ } function clearSimulationDraftMirror() { - const key = getSimulationDraftStorageKey(); - if (!key || !global.sessionStorage) { + const name = getSimulationDraftSessionName(); + if (!name || !global.AppData?.recovery?.windowSession) { return; } try { - global.sessionStorage.removeItem(key); + global.AppData.recovery.windowSession.discard(name); } catch (_) { // ignore sessionStorage failures in restricted environments } @@ -4748,13 +6189,94 @@ answers, highlights: collectHighlights(), noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), scrollY: global.scrollY || 0, updatedAt }; } + function canSyncReadingDraft() { + return Boolean( + !state.simulationMode + && !state.reviewMode + && !state.readOnly + && !state.timerLocked + && !state.submitted + && !state.memorizeMode + && state.examId + && state.sessionId + && state.windowSessionToken + ); + } + + function syncReadingDraftSnapshot(reason = 'periodic') { + if (!canSyncReadingDraft()) { + return; + } + const draft = collectCurrentDraft(); + const fingerprint = buildDraftFingerprint(draft); + if (reason === 'periodic' && fingerprint && fingerprint === state.readingDraftFingerprint) { + return; + } + state.readingDraftFingerprint = fingerprint; + const mirroredDraft = cloneDraftSafely(draft); + if (!mirroredDraft) { + return; + } + postMessage('READING_DRAFT_SYNC', { + examId: state.examId, + sessionId: state.sessionId || null, + windowSessionToken: state.windowSessionToken || null, + draft: mirroredDraft, + draftUpdatedAt: Number.isFinite(Number(mirroredDraft.updatedAt)) ? Number(mirroredDraft.updatedAt) : Date.now(), + elapsed: getPageElapsedSeconds(), + reason + }); + } + + function stopReadingDraftSync() { + if (state.readingDraftSyncTimer) { + clearInterval(state.readingDraftSyncTimer); + state.readingDraftSyncTimer = null; + } + } + + function refreshReadingDraftSyncLifecycle() { + if (!canSyncReadingDraft()) { + stopReadingDraftSync(); + return; + } + if (!state.readingDraftSyncTimer) { + state.readingDraftSyncTimer = setInterval(() => { + syncReadingDraftSnapshot('periodic'); + }, READING_DRAFT_SYNC_MS); + } + syncReadingDraftSnapshot('activate'); + } + + function flushReadingDraftOnLifecycle(reason = 'pagehide') { + if (canSyncReadingDraft()) { + syncReadingDraftSnapshot(reason); + return; + } + // 草稿同步在 submitted/只读态被跳过;但单篇 final-submit 后若宿主已回传 + // submittedRecordId,结果页笔记改动仍需要落库——这里同步触发一次标注同步, + // 防止页面在 450ms 防抖触发前关闭/隐藏而丢失 READING_ANNOTATION_SYNC。 + if (state.submitted && state.submittedRecordId && !state.memorizeMode && !state.reviewMode) { + syncReadingAnnotation(reason); + } + } + function syncSimulationDraftSnapshot(reason = 'periodic') { - if (!state.simulationMode || state.readOnly || !state.suiteSessionId) { + if (state.timerLocked) return; + const isSuiteReviewAnnotation = Boolean( + state.suiteReviewMode + && state.reviewMode + && state.suiteSessionId + ); + if (!state.simulationMode || (state.readOnly && !isSuiteReviewAnnotation) || !state.suiteSessionId) { return; } const draft = state.suite?.inline @@ -4912,8 +6434,10 @@ if (Array.isArray(draft.highlights)) { applyHighlights(draft.highlights); } - if (typeof draft.noteText === 'string') { - setNotesText(draft.noteText); + setNotes(draft.notes, draft.noteOutlines, { legacyText: draft.noteText }); + state.markedQuestions = normalizeMarkedQuestions(draft.markedQuestions); + if (typeof global.setPracticeMarkedQuestions === 'function') { + try { global.setPracticeMarkedQuestions(state.markedQuestions); } catch (_) { /* ignore */ } } if (typeof draft.scrollY === 'number') { global.scrollTo(0, draft.scrollY); @@ -4930,6 +6454,7 @@ if (!shared) { return []; } + ensureNoteAnchorsBeforeSnapshot(); return shared.snapshotHighlights({ left: dom.left, groups: dom.groups @@ -4968,6 +6493,9 @@ answers: results.answers || {}, highlights: collectHighlights(), noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), scrollY: global.scrollY || 0, elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0), timerSnapshot, @@ -5045,6 +6573,9 @@ questionTypePerformance: results.questionTypePerformance || {}, highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [], noteText: typeof draft.noteText === 'string' ? draft.noteText : '', + notes: normalizeNotes(draft.notes), + noteOutlines: normalizeNoteOutlines(draft.noteOutlines), + markedQuestions: normalizeMarkedQuestions(draft.markedQuestions), scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0, updatedAt: Number.isFinite(Number(draft.updatedAt)) ? Number(draft.updatedAt) : Date.now() }); @@ -5078,6 +6609,9 @@ scoreInfo, highlights: [], noteText: '', + notes: [], + noteOutlines: [], + markedQuestions: [], scrollY: global.scrollY || 0, elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0), timerSnapshot, @@ -5090,6 +6624,7 @@ input.checked = false; }); document.querySelectorAll('input[type="text"], textarea').forEach((input) => { + if (input.closest('#notes-panel, #reading-note-editor, #reading-note-drawer')) return; input.value = ''; }); document.querySelectorAll('select').forEach((select) => { @@ -5129,6 +6664,9 @@ answers: snapshot.answers || {}, highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [], noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '', + notes: normalizeNotes(snapshot.notes), + noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines), + markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions), scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0, updatedAt: Number.isFinite(Number(snapshot.updatedAt)) ? Number(snapshot.updatedAt) : Date.now() }, @@ -5137,6 +6675,9 @@ answers: snapshot.answers || {}, highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [], noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '', + notes: normalizeNotes(snapshot.notes), + noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines), + markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions), scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0, elapsed: Number.isFinite(Number(snapshot.elapsed)) ? Number(snapshot.elapsed) : getPageElapsedSeconds(), timerSnapshot: snapshot.timerSnapshot || getPracticeTimerSnapshot() @@ -5154,7 +6695,7 @@ handleExitClick(); return; } - if (state.readOnly) { + if (state.readOnly || state.submissionStatus !== 'draft') { return; } const submissionSnapshot = state.suite?.inline @@ -5169,15 +6710,12 @@ ? (Array.isArray(activeSlot?.draft?.highlights) ? activeSlot.draft.highlights : []) : (Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : []); const postedResults = submissionSnapshot.results || results; - state.lastResults = results; if (activeSlot) { activeSlot.lastResults = results; } - renderResults(results); - enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit'); const messageType = state.simulationMode ? 'SIMULATION_SUBMIT' : 'PRACTICE_COMPLETE'; const timing = resolvePracticeTiming(1, submissionSnapshot.timerSnapshot); - postMessage(messageType, Object.assign({ + beginSubmission(messageType, Object.assign({ duration: timing.duration, startTime: new Date(timing.startTimeMs).toISOString(), endTime: new Date(timing.endTimeMs).toISOString(), @@ -5197,25 +6735,22 @@ dataKey: state.dataKey, markedQuestions: (typeof global.getPracticeMarkedQuestions === 'function') ? global.getPracticeMarkedQuestions() - : [] + : normalizeMarkedQuestions(submissionSnapshot.markedQuestions) }, answers: submissionSnapshot.answers || {}, highlights: Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : [], noteText: typeof submissionSnapshot.noteText === 'string' ? submissionSnapshot.noteText : '', + notes: normalizeNotes(submissionSnapshot.notes), + noteOutlines: normalizeNoteOutlines(submissionSnapshot.noteOutlines), + markedQuestions: normalizeMarkedQuestions(submissionSnapshot.markedQuestions), scrollY: Number.isFinite(Number(submissionSnapshot.scrollY)) ? Number(submissionSnapshot.scrollY) : 0 }, state.suite?.inline ? { suiteSubmission: true, suiteEntries: Array.isArray(submissionSnapshot.suiteEntries) ? submissionSnapshot.suiteEntries : [] - } : {}, postedResults)); - await renderExplanations(); - applyHighlights(highlightSnapshot); - enhanceReviewHighlights(); - updateNavStatuses(results); - if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) { - stopSimulationDraftSync(); - clearSimulationDraftMirror(); - state.simulationDraftFingerprint = ''; - } + } : {}, postedResults), { + results, + highlights: highlightSnapshot + }); } function handleReset() { @@ -5226,6 +6761,7 @@ if (state.submitted && state.readOnlyReason === 'final-submit' && !state.suiteSessionId && !state.reviewMode) { resetToAnsweringPresentation(); clearCurrentAnswers(); + clearStructuredNotesForReset(); requestNormalPracticeRestart('retake-after-submit'); return; } @@ -5234,6 +6770,7 @@ } closeReviewHighlightDictionary(); clearCurrentAnswers(); + clearStructuredNotesForReset(); if (dom.results) { dom.results.style.display = 'none'; dom.results.innerHTML = ''; @@ -5251,7 +6788,7 @@ const opener = global.opener && !global.opener.closed ? global.opener : null; if (hasEndlessMarker && opener) { try { - opener.postMessage({ type: 'ENDLESS_USER_EXIT' }, '*'); + postMessage('ENDLESS_USER_EXIT', {}); if (typeof opener.stopEndlessPractice === 'function') { opener.stopEndlessPractice(); } else if (opener.AppActions && typeof opener.AppActions.stopEndlessPractice === 'function') { @@ -5352,6 +6889,9 @@ const data = payload.data || {}; const sourceWindow = event && typeof event === 'object' ? (event.source || null) : null; if (type === 'INIT_SESSION' || type === 'INIT_EXAM_SESSION') { + if (!acceptHostInitMessage(event, payload, data)) { + return; + } if (!shouldAcceptWindowSessionMessage(data, sourceWindow)) { return; } @@ -5378,6 +6918,12 @@ if (incomingExamId && !currentExamId) { state.examId = incomingExamId; } + if (data.sessionId && state.sessionId && String(data.sessionId) !== String(state.sessionId)) { + clearSubmissionAckTimer(); + state.submissionStatus = 'draft'; + state.submissionId = ''; + state.pendingSubmissionPresentation = null; + } if (data.sessionId) { state.sessionId = data.sessionId; } @@ -5455,14 +7001,28 @@ } if (data.reviewMode) { state.reviewMode = true; + // init 中的 review 模式同样不应沿用单篇 submitted 回传的 recordId。 + state.submittedRecordId = ''; if (data.readOnly !== false) { enterSubmittedReadOnlyState('stationary-review'); } else { setReadOnlyMode(false); } } + const singleDraft = !state.simulationMode + && !state.reviewMode + && data + && data.draft + && typeof data.draft === 'object' + ? data.draft + : null; + if (singleDraft) { + applyDraftToDom(singleDraft); + state.readingDraftFingerprint = buildDraftFingerprint(singleDraft); + } syncPrimaryActionButtons(); refreshSimulationDraftSyncLifecycle(); + refreshReadingDraftSyncLifecycle(); syncSuiteModeState(); stopInitLoop(); state.lastInitSignature = initSignature; @@ -5472,6 +7032,9 @@ sendSessionReady(); return; } + if (!isTrustedHostMessage(event, payload, data)) { + return; + } if (type === 'REPLAY_PRACTICE_RECORD') { const replaySignature = buildReplaySignature(data || {}); if (replaySignature && replaySignature === state.lastReplaySignature) { @@ -5485,6 +7048,40 @@ applyReviewContext(data || {}); return; } + if (type === 'PRACTICE_SUBMIT_ACK') { + await acceptSubmissionAcknowledgement(data || {}); + return; + } + if (type === 'PRACTICE_SUBMIT_FAILED') { + if (matchesPendingSubmission(data || {})) { + restoreDraftSubmissionState(String(data.submissionId || '')); + } + return; + } + if (type === 'VOCAB_HIGHLIGHT_SAVE_ACK' || type === 'VOCAB_HIGHLIGHT_SAVE_FAILED') { + const dictionary = getReviewHighlightDictionary(); + if (dictionary && typeof dictionary.handleSaveOutcome === 'function') { + dictionary.handleSaveOutcome(data || {}, type === 'VOCAB_HIGHLIGHT_SAVE_ACK'); + } + return; + } + if (type === 'PRACTICE_RECORD_SAVED') { + // 宿主在单篇阅读 final-submit 落库成功后回传已存档 recordId, + // 用于支持结果页笔记改动的持久化(syncReadingAnnotation 的 submitted 分支)。 + const payloadExamId = data && data.examId != null ? String(data.examId).trim() : ''; + const currentExamId = state.examId != null ? String(state.examId).trim() : ''; + if (payloadExamId && currentExamId && payloadExamId !== currentExamId && !state.suite?.inline) { + return; + } + const payloadSessionId = data && data.sessionId != null ? String(data.sessionId).trim() : ''; + const currentSessionId = state.sessionId != null ? String(state.sessionId).trim() : ''; + if (!payloadSessionId || !currentSessionId || payloadSessionId !== currentSessionId) { + return; + } + const recordId = data && data.recordId != null ? String(data.recordId).trim() : ''; + state.submittedRecordId = recordId; + return; + } if (type === 'SUITE_NAVIGATE' && data.url) { const targetSuiteSessionId = typeof data.suiteSessionId === 'string' ? data.suiteSessionId.trim() : ''; const currentSuiteSessionId = typeof state.suiteSessionId === 'string' ? state.suiteSessionId.trim() : ''; @@ -5641,6 +7238,30 @@ global.addEventListener('message', handleIncoming); } + function attachReadingDraftLifecycleHooks() { + const flush = (reason) => { + try { + // 先把编辑器里未提交的笔记立刻刷出:review 页面 flushReadingDraftOnLifecycle + // 会因 canSyncReadingDraft 直接 no-op,笔记只能靠 450ms 防抖提交,页面在 + // 防抖触发前关闭/隐藏就会丢失 READING_ANNOTATION_SYNC。这里同步触发一次, + // review 路径在同步里发出最新的 note,正常阅读路径则继续走 draft 快照。 + if (typeof flushActiveNoteFromEditor === 'function') { + flushActiveNoteFromEditor(); + } + flushReadingDraftOnLifecycle(reason); + } catch (_) { + // ignore draft flush failures during teardown + } + }; + global.addEventListener('pagehide', () => flush('pagehide')); + global.addEventListener('beforeunload', () => flush('beforeunload')); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flush('visibilitychange'); + } + }); + } + function attachPracticeTimerBridge() { global.addEventListener(PRACTICE_TIMER_EVENT, (event) => { const detail = event && event.detail && typeof event.detail === 'object' @@ -5654,6 +7275,8 @@ } async function bootstrap() { + await loadReadingCandidateCodePreferences(); + if (global.PracticeTimerPreferences?.ready) await global.PracticeTimerPreferences.ready; parseQuery(); captureDom(); const dataset = await ensureDataset(); @@ -5686,11 +7309,15 @@ attachUnifiedTimer(); attachUnifiedPanels(); + ensureReadingNotesUi(); + ensureReadingDisplayControls(); + await loadReadingDisplayPreferences(); attachSelectionHighlightToolbar(); attachReviewHighlightDictionary(); attachActionListeners(); attachMessageBridge(); attachPracticeTimerBridge(); + attachReadingDraftLifecycleHooks(); syncSuiteModeState(); setExitButtonVisible(false); if (state.memorizeMode) { @@ -5698,6 +7325,7 @@ } updateNavStatuses(); refreshSimulationDraftSyncLifecycle(); + refreshReadingDraftSyncLifecycle(); startInitLoop(); } diff --git a/js/services/achievementManager.js b/js/services/achievementManager.js index fb089be2..010a31b0 100644 --- a/js/services/achievementManager.js +++ b/js/services/achievementManager.js @@ -1,33 +1,99 @@ (function (window) { 'use strict'; + /** + * Presentation catalog + notifier for achievements. + * + * Unlock rules and persistence belong entirely to the `achievements.progress` + * projector (js/data/v2/appData.js -> computeAchievementProgress). That projector + * is declared `derived` in the data catalog, is listed in `derivedPending` for every + * practice mutation, and records the historically accurate unlock timestamp for each + * achievement id. + * + * This class therefore owns only display metadata (title / description / icon / tier) + * and diffs successive projector reads so that newly unlocked achievements can be + * surfaced as notifications. It deliberately does NOT re-derive unlock conditions: + * a second rule engine here would drift from the projector (it previously did, which + * left every streak achievement permanently locked) and would stamp "unlocked now" + * instead of the real unlock time. + */ class AchievementManager { constructor() { - this.storageKey = 'user_achievements'; this.achievements = this._defineAchievements(); + this.achievementIds = new Set(this.achievements.map((item) => item.id)); this.listeners = []; this.initialized = false; + // Newest read — what the achievements modal renders. this.unlocked = {}; + // Last read whose projector provenance was proven — what the unlock diff measures + // against. Deliberately separate from `unlocked`: see syncFromAppData. + this.baseline = {}; + this.baselineFresh = false; + this._deliveryInitialized = false; + this._pendingDelivery = {}; + this._initPromise = null; + this._syncTail = Promise.resolve(); } /** - * Initialize the manager, loading state from storage + * Initialize the manager, loading persisted progress from storage. + * + * The first run seeds a durable delivery baseline so existing users are not greeted with + * every historical unlock. Later runs diff against that persisted acknowledgement instead + * of the first projector read, which lets a pending unlock survive a page restart. */ async init() { if (this.initialized) return; + if (this._initPromise) return this._initPromise; + this._initPromise = this._enqueueSync(() => this._initialize()).finally(() => { + this._initPromise = null; + }); + return this._initPromise; + } + + async _initialize() { try { - this.unlocked = await this._loadUnlockedState(); + let [state, delivery] = await Promise.all([ + this._loadUnlockedState(), + this._loadDeliveryState() + ]); + state = await this._retryUntilFresh(state); + this.unlocked = state.unlocked; + if (delivery) { + this.baseline = delivery.acknowledged; + this.baselineFresh = true; + this._deliveryInitialized = true; + } else { + this.baseline = state.unlocked; + this.baselineFresh = state.fresh; + // A brand-new store has no projector provenance yet, but its empty snapshot is + // still a safe delivery baseline: there is no historical unlock to suppress. + if (state.fresh || Object.keys(state.unlocked).length === 0) { + await this._persistDeliveryBaseline(state.unlocked); + this._deliveryInitialized = true; + } + } console.log('[AchievementManager] Initialized. Unlocked:', Object.keys(this.unlocked).length); this.initialized = true; + + if (delivery) { + await this._syncFromAppDataNow({ notify: true, initialState: state }); + } } catch (e) { console.error('[AchievementManager] Init failed', e); this.unlocked = {}; + this.baseline = {}; + this.baselineFresh = false; + this._deliveryInitialized = false; + this.initialized = false; + throw e; } } /** - * Define the list of available achievements + * Display metadata for every achievement the projector can unlock. + * Ids must stay in sync with computeAchievementProgress in js/data/v2/appData.js. */ _defineAchievements() { return [ @@ -37,32 +103,28 @@ title: '初出茅庐', description: '累计完成 10 次练习', icon: '🥉', - tier: 1, - condition: (stats) => stats.totalPracticed >= 10 + tier: 1 }, { id: 'practice_silver', title: '渐入佳境', description: '累计完成 50 次练习', icon: '🥈', - tier: 2, - condition: (stats) => stats.totalPracticed >= 50 + tier: 2 }, { id: 'practice_gold', title: '百炼成钢', description: '累计完成 100 次练习', icon: '🥇', - tier: 3, - condition: (stats) => stats.totalPracticed >= 100 + tier: 3 }, { id: 'practice_platinum', title: '千锤百炼', description: '累计完成 200 次练习', icon: '🏅', - tier: 3, - condition: (stats) => stats.totalPracticed >= 200 + tier: 3 }, // --- Streak Milestones --- @@ -71,32 +133,28 @@ title: '持之以恒', description: '连续学习 3 天', icon: '🔥', - tier: 1, - condition: (stats) => stats.streakDays >= 3 + tier: 1 }, { id: 'streak_silver', title: '习惯养成', description: '连续学习 7 天', icon: '🔥', - tier: 2, - condition: (stats) => stats.streakDays >= 7 + tier: 2 }, { id: 'streak_gold', title: '意志如铁', description: '连续学习 30 天', icon: '🔥', - tier: 3, - condition: (stats) => stats.streakDays >= 30 + tier: 3 }, { id: 'streak_platinum', title: '长期主义', description: '连续学习 60 天', icon: '🗓️', - tier: 3, - condition: (stats) => stats.streakDays >= 60 + tier: 3 }, // --- Category Mastery: Listening --- @@ -105,32 +163,28 @@ title: '开耳第一篇', description: '完成 1 篇听力练习', icon: '🎧', - tier: 1, - condition: (stats) => stats.listeningCount >= 1 + tier: 1 }, { id: 'listening_bronze', title: '顺风耳 (铜)', description: '累计完成 10 篇听力练习', icon: '👂', - tier: 1, - condition: (stats) => stats.listeningCount >= 10 + tier: 1 }, { id: 'listening_silver', title: '顺风耳 (银)', description: '累计完成 50 篇听力练习', icon: '👂', - tier: 2, - condition: (stats) => stats.listeningCount >= 50 + tier: 2 }, { id: 'listening_gold', title: '顺风耳 (金)', description: '累计完成 100 篇听力练习', icon: '👂', - tier: 3, - condition: (stats) => stats.listeningCount >= 100 + tier: 3 }, // --- Category Mastery: Reading --- @@ -139,32 +193,28 @@ title: '开卷第一篇', description: '完成 1 篇阅读练习', icon: '📖', - tier: 1, - condition: (stats) => stats.readingCount >= 1 + tier: 1 }, { id: 'reading_bronze', title: '火眼金睛 (铜)', description: '累计完成 10 篇阅读练习', icon: '👁️', - tier: 1, - condition: (stats) => stats.readingCount >= 10 + tier: 1 }, { id: 'reading_silver', title: '火眼金睛 (银)', description: '累计完成 50 篇阅读练习', icon: '👁️', - tier: 2, - condition: (stats) => stats.readingCount >= 50 + tier: 2 }, { id: 'reading_gold', title: '火眼金睛 (金)', description: '累计完成 100 篇阅读练习', icon: '👁️', - tier: 3, - condition: (stats) => stats.readingCount >= 100 + tier: 3 }, // --- Balanced Practice --- @@ -173,16 +223,14 @@ title: '双线推进', description: '阅读与听力各完成 10 篇', icon: '⚖️', - tier: 2, - condition: (stats) => stats.readingCount >= 10 && stats.listeningCount >= 10 + tier: 2 }, { id: 'balanced_advanced', title: '均衡进阶', description: '阅读与听力各完成 30 篇', icon: '🧭', - tier: 3, - condition: (stats) => stats.readingCount >= 30 && stats.listeningCount >= 30 + tier: 3 }, // --- Focus Time --- @@ -191,24 +239,21 @@ title: '专注一小时', description: '累计学习 60 分钟', icon: '⏱️', - tier: 1, - condition: (stats) => stats.totalStudyMinutes >= 60 + tier: 1 }, { id: 'time_focus_300', title: '沉浸五小时', description: '累计学习 300 分钟', icon: '⏳', - tier: 2, - condition: (stats) => stats.totalStudyMinutes >= 300 + tier: 2 }, { id: 'time_focus_1000', title: '深度备考', description: '累计学习 1000 分钟', icon: '⌛', - tier: 3, - condition: (stats) => stats.totalStudyMinutes >= 1000 + tier: 3 }, // --- Accuracy Milestones --- @@ -217,48 +262,42 @@ title: '稳中有进', description: '10 次练习后平均正确率 70%+', icon: '📈', - tier: 2, - condition: (stats) => stats.totalPracticed >= 10 && stats.averageAccuracy >= 0.7 + tier: 2 }, { id: 'accuracy_elite', title: '高分稳定', description: '20 次练习后平均正确率 85%+', icon: '💎', - tier: 3, - condition: (stats) => stats.totalPracticed >= 20 && stats.averageAccuracy >= 0.85 + tier: 3 }, { id: 'perfect_three', title: '三次满分', description: '累计 3 次练习获得满分', icon: '🎯', - tier: 2, - condition: (stats) => stats.perfectCount >= 3 + tier: 2 }, { id: 'perfect_ten', title: '十全十美', description: '累计 10 次练习获得满分', icon: '🏆', - tier: 3, - condition: (stats) => stats.perfectCount >= 10 + tier: 3 }, { id: 'speed_three', title: '快速稳定', description: '3 次 5 分钟内完成高分练习', icon: '⚡', - tier: 2, - condition: (stats) => stats.speedHighScoreCount >= 3 + tier: 2 }, { id: 'speed_ten', title: '闪电节奏', description: '10 次 5 分钟内完成高分练习', icon: '🌩️', - tier: 3, - condition: (stats) => stats.speedHighScoreCount >= 10 + tier: 3 }, // --- Special Achievements --- @@ -267,383 +306,208 @@ title: '迈出第一步', description: '完成第一次练习', icon: '🌱', - tier: 1, - condition: (stats) => stats.totalPracticed >= 1 + tier: 1 }, { id: 'accuracy_perfect', title: '神射手', description: '单次练习获得 100% 正确率', icon: '🎯', - tier: 3, - condition: (stats) => stats.hasPerfectAccuracy + tier: 3 }, { id: 'speed_demon', title: '唯快不破', description: '5分钟内完成高分练习', icon: '⚡', - tier: 3, - condition: (stats) => stats.hasSpeedDemon + tier: 3 } ]; } /** - * Load unlocked state from storage + * Read projector-owned unlock progress from storage. + * + * `AppData.achievements.getAll()` attaches a non-enumerable `fresh` flag: false means the + * projector was still pending and the payload is an inline recompute rather than the proven + * cache. That distinction is load-bearing for the unlock diff and delivery retry. */ async _loadUnlockedState() { - if (window.storage) { - return await window.storage.get(this.storageKey, {}); - } - const raw = localStorage.getItem(this.storageKey); - return raw ? JSON.parse(raw) : {}; - } - - /** - * Save unlocked state to storage - */ - async _saveUnlockedState() { - if (window.storage) { - await window.storage.set(this.storageKey, this.unlocked); - return; - } - localStorage.setItem(this.storageKey, JSON.stringify(this.unlocked)); - } - - _getDefaultUserStats() { + const progress = await window.AppData.achievements.getAll(); return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [] + unlocked: this._normalizeProgress(progress), + fresh: !progress || progress.fresh !== false }; } - _getPracticeRecorder() { - const app = window.app; - if (app && app.components && app.components.practiceRecorder) { - return app.components.practiceRecorder; - } - return null; - } - - async _getUserStatsFromPracticeRecordAPI() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - return await window.PracticeRecordAPI.readStats(); - } - const recorder = this._getPracticeRecorder(); - if (recorder && typeof recorder.getUserStats === 'function') { - return await recorder.getUserStats(); + async _loadDeliveryState() { + const settings = await window.AppData.settings.getAll(); + const delivery = settings && settings.achievementDelivery; + if (!delivery || delivery.version !== 1 || !delivery.acknowledged + || typeof delivery.acknowledged !== 'object' || Array.isArray(delivery.acknowledged)) { + return null; } - return this._getDefaultUserStats(); - } - - /** @deprecated Use _getUserStatsFromPracticeRecordAPI */ - async _getUserStatsFromScoreStorage() { - return this._getUserStatsFromPracticeRecordAPI(); + return { + acknowledged: Object.fromEntries(Object.entries(delivery.acknowledged) + .filter(([id]) => this.achievementIds.has(id)) + .map(([id, unlockedAt]) => [id, { unlockedAt: unlockedAt || null }])) + }; } - async _getPracticeRecordsFromPracticeRecordAPI() { - // 使用轻量 listSummary:achievementManager 只需 type/accuracy/duration 等元数据, - // 不需要 answers/correctAnswerMap/suiteEntries 等重字段。listSummary 已从 scoreInfo 投影了 - // accuracy/duration/score 等字段,无需依赖 realData.scoreInfo 后备路径。 - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') { - return await window.PracticeRecordAPI.listSummary(); - } - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return await window.PracticeRecordAPI.list(); - } - const recorder = this._getPracticeRecorder(); - if (recorder && typeof recorder.getPracticeRecords === 'function') { - return await recorder.getPracticeRecords(); + async _persistDeliveryBaseline(unlocked) { + if (!window.AppData.achievements + || typeof window.AppData.achievements.acknowledgeDelivery !== 'function') { + throw new Error('AppData.achievements.acknowledgeDelivery is required'); } - return []; + await window.AppData.achievements.acknowledgeDelivery(unlocked); } - /** @deprecated Use _getPracticeRecordsFromPracticeRecordAPI */ - async _getPracticeRecordsFromScoreStorage() { - return this._getPracticeRecordsFromPracticeRecordAPI(); + _unionBaseline(...sources) { + const merged = {}; + sources.forEach((source) => { + Object.entries(source && typeof source === 'object' ? source : {}).forEach(([id, value]) => { + if (!this.achievementIds.has(id)) return; + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const candidateTime = typeof candidate === 'string' ? Date.parse(candidate) : NaN; + const prior = merged[id] && merged[id].unlockedAt; + const priorTime = typeof prior === 'string' ? Date.parse(prior) : NaN; + if (!merged[id] || (Number.isFinite(candidateTime) + && (!Number.isFinite(priorTime) || candidateTime < priorTime))) { + merged[id] = { unlockedAt: Number.isFinite(candidateTime) + ? new Date(candidateTime).toISOString() + : null }; + } + }); + }); + return merged; } - _getCategoryPracticeCount(stats, targetKey) { - if (!stats || !stats.categoryStats || typeof stats.categoryStats !== 'object') { - return 0; + async _retryUntilFresh(initialState) { + let state = initialState; + if (state.fresh || !window.AppData.achievements + || typeof window.AppData.achievements.retryPending !== 'function') { + return state; } - - const normalizedTarget = String(targetKey || '').toLowerCase(); - let count = 0; - - Object.entries(stats.categoryStats).forEach(([key, value]) => { - const normalizedKey = String(key || '').toLowerCase(); - if (normalizedKey !== normalizedTarget) { - return; + for (let attempt = 0; attempt < 3 && !state.fresh; attempt += 1) { + try { + await window.AppData.achievements.retryPending(); + state = await this._loadUnlockedState(); + } catch (err) { + console.warn('[AchievementManager] Failed to retry pending achievement projection', err); } - const practices = value && Number(value.practices); - if (Number.isFinite(practices)) { - count += practices; + if (!state.fresh && attempt < 2) { + await new Promise((resolve) => { + const schedule = window.setTimeout || ((callback) => callback()); + schedule(resolve, 10 * (2 ** attempt)); + }); } - }); - - return count; - } - - _normalizePracticeType(rawType) { - if (!rawType) { - return null; } - - const normalized = String(rawType).toLowerCase(); - if (normalized.includes('listen') || normalized.includes('audio') || normalized.includes('hearing')) { - return 'listening'; - } - if (normalized.includes('read')) { - return 'reading'; - } - return null; + return state; } - _inferRecordPracticeType(record) { - if (!record || typeof record !== 'object') { - return null; - } - - const metadata = record.metadata && typeof record.metadata === 'object' - ? record.metadata + /** + * Reduce the projector payload to `{ [id]: { unlockedAt } }` for ids this + * catalog can render. Unknown ids (e.g. manual entries for retired achievements) + * are dropped because there is no card to show them on. + */ + _normalizeProgress(progress) { + const source = progress && typeof progress === 'object' && !Array.isArray(progress) + ? progress : {}; - const candidates = [ - record.type, - record.practiceType, - metadata.type, - metadata.examType, - metadata.practiceType - ]; + const normalized = {}; - for (const candidate of candidates) { - const normalized = this._normalizePracticeType(candidate); - if (normalized) { - return normalized; + Object.entries(source).forEach(([id, value]) => { + if (!value || id === 'updatedAt' || !this.achievementIds.has(id)) { + return; } - } - - const contextHints = [ - record.examId, - record.url, - record.title, - metadata.url, - metadata.examId, - metadata.examTitle, - metadata.title - ] - .filter(Boolean) - .map((item) => String(item).toLowerCase()) - .join(' '); - - if (/listeningpractice|\/listening\/|listen|audio/.test(contextHints)) { - return 'listening'; - } - if (/reading|睡着过项目组/.test(contextHints)) { - return 'reading'; - } + const unlockedAt = value && typeof value === 'object' ? value.unlockedAt : null; + normalized[id] = { unlockedAt: unlockedAt || null }; + }); - return null; + return normalized; } - _normalizeAccuracy(record) { - if (!record || typeof record !== 'object') { - return 0; - } - - const scoreInfo = record.scoreInfo && typeof record.scoreInfo === 'object' - ? record.scoreInfo - : (record.realData && record.realData.scoreInfo && typeof record.realData.scoreInfo === 'object' - ? record.realData.scoreInfo - : {}); - - const candidates = [ - record.accuracy, - scoreInfo.accuracy - ]; - - for (const candidate of candidates) { - const value = Number(candidate); - if (!Number.isFinite(value)) { - continue; - } - if (value > 1 && value <= 100) { - return value / 100; - } - return Math.max(0, Math.min(1, value)); - } - - return 0; + /** + * Re-read projector progress and report achievements unlocked since the last proven read. + * + * Freshness gates the baseline, not the display. `this.unlocked` always tracks the newest + * read so the achievements modal never renders yesterday's state, while `this.baseline` — + * the set the unlock diff is measured against — only advances on a read whose provenance the + * projector proved. An unproven read that quietly became the baseline would make the next + * read see the unlock as "already known" and drop its notification for good, which is the + * one failure mode with no recovery path: there is no later event that re-raises it. + * + * Consequences of that split: an unproven read never notifies (announcing an unlock the + * proven projection has not confirmed risks a toast for something that never happened, e.g. + * a source snapshot read mid-import), and it never consumes one either — the very next + * proven read still sees the unlock as new and raises it exactly once. + * + * @param {Object} options + * @param {boolean} [options.notify] - surface a toast for each new unlock + */ + syncFromAppData(options = {}) { + return this._enqueueSync(() => this._syncFromAppDataNow(options)); } - _getRecordDuration(record) { - if (!record || typeof record !== 'object') { - return 0; - } - - const scoreInfo = record.scoreInfo && typeof record.scoreInfo === 'object' - ? record.scoreInfo - : (record.realData && record.realData.scoreInfo && typeof record.realData.scoreInfo === 'object' - ? record.realData.scoreInfo - : {}); - - const candidates = [ - record.duration, - record.realData && record.realData.duration, - scoreInfo.duration, - scoreInfo.timeSpent - ]; - - for (const candidate of candidates) { - const value = Number(candidate); - if (Number.isFinite(value) && value >= 0) { - return value; - } - } - - return 0; + _enqueueSync(run) { + const result = this._syncTail.then(run, run); + this._syncTail = result.catch(() => {}); + return result; } - _applyRecordsToDerivedStats(derived, records) { - if (!derived || !Array.isArray(records) || records.length === 0) { - return; - } - - let listeningFromRecords = 0; - let readingFromRecords = 0; - let totalFromRecords = 0; - let totalAccuracyFromRecords = 0; - let accuracyRecordCount = 0; - let totalDurationFromRecords = 0; - - records.forEach((record) => { - if (!record || typeof record !== 'object') { - return; - } - - totalFromRecords += 1; - - const practiceType = this._inferRecordPracticeType(record); - if (practiceType === 'listening') { - listeningFromRecords += 1; - } else if (practiceType === 'reading') { - readingFromRecords += 1; - } - - const accuracy = this._normalizeAccuracy(record); - const duration = this._getRecordDuration(record); - totalAccuracyFromRecords += accuracy; - accuracyRecordCount += 1; - totalDurationFromRecords += duration; - this._applyRecordToDerivedStats(derived, { accuracy, duration }); - }); + async _syncFromAppDataNow(options = {}) { + const { notify = false } = options; + const baseline = this.baseline && typeof this.baseline === 'object' ? this.baseline : {}; - derived.totalPracticed = Math.max(Number(derived.totalPracticed) || 0, totalFromRecords); - derived.listeningCount = Math.max(Number(derived.listeningCount) || 0, listeningFromRecords); - derived.readingCount = Math.max(Number(derived.readingCount) || 0, readingFromRecords); - derived.totalStudyMinutes = Math.max( - Number(derived.totalStudyMinutes) || 0, - totalDurationFromRecords / 60 - ); - if (accuracyRecordCount > 0) { - derived.averageAccuracy = Math.max( - Number(derived.averageAccuracy) || 0, - totalAccuracyFromRecords / accuracyRecordCount - ); + let state = options.initialState || null; + try { + if (!state) state = await this._loadUnlockedState(); + } catch (err) { + console.warn('[AchievementManager] Failed to read achievement progress', err); + return []; } - } - _buildDerivedStats(rawStats) { - const stats = rawStats && typeof rawStats === 'object' ? rawStats : {}; - const averageScore = Number(stats.averageScore) || 0; - return { - totalPracticed: Number(stats.totalPractices) || 0, - streakDays: Number(stats.streakDays) || 0, - totalStudyMinutes: (Number(stats.totalTimeSpent) || 0) / 60, - averageAccuracy: averageScore > 1 && averageScore <= 100 ? averageScore / 100 : averageScore, - listeningCount: this._getCategoryPracticeCount(stats, 'listening'), - readingCount: this._getCategoryPracticeCount(stats, 'reading'), - hasPerfectAccuracy: false, - hasSpeedDemon: false, - perfectCount: 0, - speedHighScoreCount: 0 - }; - } + state = await this._retryUntilFresh(state); - _applyRecordToDerivedStats(derived, record) { - if (!derived || !record) { - return; + const current = state.unlocked; + this.unlocked = current; + if (!state.fresh) { + // Derived cache was unproven (projector pending): display refreshed, baseline held. + this.baselineFresh = false; + return []; } - const accuracy = Number(record.accuracy) || 0; - const duration = Number(record.duration) || 0; - - if (accuracy >= 1) { - derived.hasPerfectAccuracy = true; - derived.perfectCount = (Number(derived.perfectCount) || 0) + 1; - } - if (duration > 0 && duration <= 300 && accuracy > 0.8) { - derived.hasSpeedDemon = true; - derived.speedHighScoreCount = (Number(derived.speedHighScoreCount) || 0) + 1; + if (!this._deliveryInitialized) { + await this._persistDeliveryBaseline(current); + this.baseline = this._unionBaseline(baseline, current); + this.baselineFresh = true; + this._deliveryInitialized = true; + return []; } - } - - async syncFromPracticeRecordAPI(options = {}) { - const { - includeRecords = false, - latestRecord = null, - notify = false - } = options; - const rawStats = await this._getUserStatsFromPracticeRecordAPI(); - const derivedStats = this._buildDerivedStats(rawStats); + const newUnlocks = this.achievements.filter((achievement) => ( + current[achievement.id] && !baseline[achievement.id] + )); - const records = await this._getPracticeRecordsFromPracticeRecordAPI(); - this._applyRecordsToDerivedStats(derivedStats, records); + this.baselineFresh = true; - if (!includeRecords) { - this._applyRecordToDerivedStats(derivedStats, latestRecord); + if (newUnlocks.length > 0 && notify) { + this._notify(newUnlocks); + // Notification delivery is at-least-once across crashes. Within this session, + // advance first so a failed persistence retry cannot repeatedly toast the user. + this.baseline = this._unionBaseline(baseline, current); + this._pendingDelivery = this._unionBaseline(this._pendingDelivery, current); + } else if (newUnlocks.length === 0) { + this.baseline = this._unionBaseline(baseline, current); } - return this._unlockByStats(derivedStats, { notify }); - } - - /** @deprecated Use syncFromPracticeRecordAPI */ - async syncFromScoreStorage(options = {}) { - return this.syncFromPracticeRecordAPI(options); - } - - async _unlockByStats(stats, options = {}) { - const { notify = false } = options; - const newUnlocks = []; - - for (const achievement of this.achievements) { - if (this.unlocked[achievement.id]) continue; - + if (Object.keys(this._pendingDelivery).length > 0) { + const pending = this._pendingDelivery; try { - if (achievement.condition(stats, null)) { - this.unlocked[achievement.id] = { - unlockedAt: new Date().toISOString() - }; - newUnlocks.push(achievement); - } + await this._persistDeliveryBaseline(pending); + this._pendingDelivery = {}; } catch (err) { - console.error(`[AchievementManager] Error checking ${achievement.id}`, err); - } - } - - if (newUnlocks.length > 0) { - await this._saveUnlockedState(); - if (notify) { - this._notify(newUnlocks); + console.warn('[AchievementManager] Failed to persist delivery acknowledgement', err); } } @@ -651,12 +515,12 @@ } /** - * Check for new achievements based on latest activity - * @param {Object} latestRecord - The practice record just completed + * Check for newly unlocked achievements after a practice completes. + * The projector has already recomputed progress by this point; we only diff it. */ - async check(latestRecord) { + async check() { if (!this.initialized) await this.init(); - return this.syncFromPracticeRecordAPI({ includeRecords: true, latestRecord, notify: true }); + return this.syncFromAppData({ notify: true }); } /** @@ -713,7 +577,7 @@ } } - await window.AchievementManager.syncFromPracticeRecordAPI({ includeRecords: true, notify: false }); + await window.AchievementManager.syncFromAppData({ notify: false }); const all = window.AchievementManager.getAll(); list.innerHTML = all.map(a => `
diff --git a/js/utils/answerComparisonUtils.js b/js/utils/answerComparisonUtils.js index 9f91bb9a..01277566 100644 --- a/js/utils/answerComparisonUtils.js +++ b/js/utils/answerComparisonUtils.js @@ -572,158 +572,19 @@ }; } - function getAllExamIndexes(globalObj) { - let readingIndex = null; - if (globalObj && typeof globalObj.getReadingExamIndex === 'function') { - try { - readingIndex = globalObj.getReadingExamIndex(); - } catch (_) { - readingIndex = null; - } - } - const sources = [ - readingIndex, - globalObj.__READING_EXAM_INDEX__, - globalObj.examIndex, - globalObj.readingExamIndex, - globalObj.listeningExamIndex, - globalObj.fullExamIndex, - globalObj.practiceExamIndex - ]; - return sources - .filter(Array.isArray) - .reduce((acc, list) => acc.concat(list), []); - } - - function normalizeTitle(title) { - return toStringKey(title) - .toLowerCase() - .replace(/[\s\-_\u3000]+/g, '') - .replace(/[^\w\u4e00-\u9fa5]/g, ''); - } - - function findExamEntry(record, metadata, globalObj) { - const indexes = getAllExamIndexes(globalObj); - if (indexes.length === 0) { - return null; - } - - const candidateIds = [ - record && record.examId, - record && record.originalExamId, - record && record.derivedExamId, - record && record.realData && record.realData.examId, - metadata && metadata.examId, - metadata && metadata.id - ] - .map(toStringKey) - .filter(Boolean); - - // 1. 精确 ID 匹配 - if (candidateIds.length > 0) { - const idLookup = new Map(); - indexes.forEach(item => { - if (!item || typeof item !== 'object') { - return; - } - const itemId = toStringKey(item.id); - if (itemId) { - idLookup.set(itemId.toLowerCase(), item); - } - }); - - for (const id of candidateIds) { - const normalizedId = id.toLowerCase(); - if (idLookup.has(normalizedId)) { - return idLookup.get(normalizedId); - } - } - } - - // 2. 通过 URL 路径匹配(针对全量题库) - if (record && record.url) { - const urlPath = record.url.toLowerCase(); - const match = indexes.find(item => { - if (!item || !item.path) return false; - const itemPath = item.path.toLowerCase(); - // 提取 URL 中的文件夹名称 - const urlParts = urlPath.split('/').filter(Boolean); - const pathParts = itemPath.split('/').filter(Boolean); - - // 检查是否有共同的文件夹路径 - for (let i = 0; i < Math.min(urlParts.length, pathParts.length); i++) { - if (urlParts[urlParts.length - 1 - i] === pathParts[pathParts.length - 1 - i]) { - return true; - } - } - return false; - }); - if (match) { - console.log('[AnswerComparisonUtils] 通过 URL 路径匹配到题目:', match.id, match.title); - return match; - } + function inferCategory(record, metadata, examEntry) { + if (metadata && metadata.category && metadata.category !== 'Unknown') { + return metadata.category; } - // 3. 精确标题匹配 - const candidateTitles = [ - metadata && metadata.examTitle, - metadata && metadata.title, - record && record.title, - record && record.examTitle, - record && record.realData && record.realData.title - ] - .map(normalizeTitle) - .filter(Boolean); - - if (candidateTitles.length > 0) { - const titleLookup = new Map(); - indexes.forEach(item => { - if (!item || typeof item !== 'object') { - return; - } - const itemTitle = normalizeTitle(item.title); - if (itemTitle) { - titleLookup.set(itemTitle, item); - } - }); - - for (const title of candidateTitles) { - if (titleLookup.has(title)) { - return titleLookup.get(title); - } - } - - // 4. 模糊标题匹配(移除标签前缀后比较) - for (const candidateTitle of candidateTitles) { - const match = indexes.find(item => { - if (!item || !item.title) return false; - const itemTitle = normalizeTitle(item.title); - // 移除标签前缀,如 "[听力全量-...] City Development" vs "City Development" - const cleanCandidate = candidateTitle.replace(/^\[.*?\]\s*/, ''); - const cleanItem = itemTitle.replace(/^\[.*?\]\s*/, ''); - return cleanCandidate === cleanItem || - (cleanCandidate.length > 5 && cleanItem.includes(cleanCandidate)) || - (cleanItem.length > 5 && cleanCandidate.includes(cleanItem)); - }); - if (match) { - console.log('[AnswerComparisonUtils] 通过模糊标题匹配到题目:', match.id, match.title); - return match; - } - } + if (record && record.category && record.category !== 'Unknown') { + return record.category; } - return null; - } - - function inferCategory(record, metadata, examEntry) { if (examEntry && examEntry.category) { return examEntry.category; } - if (metadata && metadata.category && metadata.category !== 'Unknown') { - return metadata.category; - } - const candidates = [ record && record.examId, metadata && metadata.examId, @@ -748,7 +609,7 @@ return metadata && metadata.category ? metadata.category : 'Unknown'; } - function enrichRecordMetadata(record) { + function enrichRecordMetadata(record, examEntry = null) { if (!record || typeof record !== 'object') { return { category: 'Unknown', @@ -764,25 +625,24 @@ return metadata; } - const globalObj = global || {}; - const examEntry = findExamEntry(record, metadata, globalObj); + const resolvedExam = examEntry && typeof examEntry === 'object' ? examEntry : null; - if (examEntry) { - if (examEntry.title && !metadata.examTitle) { - metadata.examTitle = examEntry.title; + if (resolvedExam) { + if (resolvedExam.title && !metadata.examTitle) { + metadata.examTitle = resolvedExam.title; } - if (examEntry.frequency && !metadata.frequency) { - metadata.frequency = examEntry.frequency; + if (resolvedExam.frequency && !metadata.frequency) { + metadata.frequency = resolvedExam.frequency; } - if (examEntry.type && !metadata.type) { - metadata.type = examEntry.type; + if (resolvedExam.type && !metadata.type) { + metadata.type = resolvedExam.type; } } - metadata.category = inferCategory(record, metadata, examEntry); + metadata.category = inferCategory(record, metadata, resolvedExam); if (!metadata.frequency) { - if (examEntry && examEntry.frequency) { - metadata.frequency = examEntry.frequency; + if (resolvedExam && resolvedExam.frequency) { + metadata.frequency = resolvedExam.frequency; } else if (metadata.frequency == null) { metadata.frequency = 'unknown'; } @@ -811,13 +671,13 @@ return metadata; } - function withEnrichedMetadata(record) { + function withEnrichedMetadata(record, examEntry = null) { if (!record || typeof record !== 'object') { return record; } const clone = Object.assign({}, record); clone.metadata = Object.assign({}, record.metadata || {}); - enrichRecordMetadata(clone); + enrichRecordMetadata(clone, examEntry); return clone; } diff --git a/js/utils/answerMatchCore.js b/js/utils/answerMatchCore.js index 1e770ea2..1453769a 100644 --- a/js/utils/answerMatchCore.js +++ b/js/utils/answerMatchCore.js @@ -120,7 +120,20 @@ function compareAnswers(userAnswer, correctAnswer) { const expected = splitAnswerTokens(correctAnswer); - const actual = splitAnswerTokens(userAnswer); + let actual = splitAnswerTokens(userAnswer); + + if ( + expected.length === 1 + && /^[A-Z]$/.test(expected[0]) + && actual.length === 1 + && !/^[A-Z]$/.test(actual[0]) + && typeof userAnswer === 'string' + ) { + const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/); + if (labeledOption) { + actual = [labeledOption[1]]; + } + } if (expected.length === 0 && actual.length === 0) { return null; diff --git a/js/utils/environmentDetector.js b/js/utils/environmentDetector.js index 9df4d550..4e92d917 100644 --- a/js/utils/environmentDetector.js +++ b/js/utils/environmentDetector.js @@ -3,34 +3,8 @@ return; } - const FLAG_KEY = '__ielts_test_env__'; const LOCATION_HINTS = ['test_env=1', 'suite_test=1', 'ci=1']; - const readStorageFlag = () => { - try { - if (global.localStorage) { - return global.localStorage.getItem(FLAG_KEY) === 'true'; - } - } catch (error) { - console.warn('[EnvDetector] 无法读取测试标记:', error); - } - return false; - }; - - const persistFlag = (value) => { - try { - if (global.localStorage) { - if (value) { - global.localStorage.setItem(FLAG_KEY, 'true'); - } else { - global.localStorage.removeItem(FLAG_KEY); - } - } - } catch (error) { - console.warn('[EnvDetector] 无法写入测试标记:', error); - } - }; - const shouldActivateFromLocation = () => { if (!global.location) { return false; @@ -47,33 +21,19 @@ } if (shouldActivateFromLocation()) { - this.enableTestEnvironment({ persist: true }); - return true; - } - - if (readStorageFlag()) { - global.__IELTS_FORCE_TEST_ENV__ = true; - return true; - } - - const userAgent = (global.navigator && global.navigator.userAgent) || ''; - if (/\b(playwright|puppeteer|headlesschrome)\b/i.test(userAgent)) { + this.enableTestEnvironment(); return true; } return false; }, - enableTestEnvironment(options = {}) { + enableTestEnvironment() { global.__IELTS_FORCE_TEST_ENV__ = true; - if (options.persist !== false) { - persistFlag(true); - } }, disableTestEnvironment() { global.__IELTS_FORCE_TEST_ENV__ = false; - persistFlag(false); } }; diff --git a/js/utils/logger.js b/js/utils/logger.js index 9cf76c43..6f6197c8 100644 --- a/js/utils/logger.js +++ b/js/utils/logger.js @@ -8,8 +8,6 @@ return; } - const STORAGE_KEY = 'exam_system_log_config_v2'; - // Default configuration const DEFAULT_CONFIG = { level: 'info', @@ -19,7 +17,7 @@ 'PerformanceOptimizer': 'warn', 'System': 'info', 'PracticeRecorder': 'info', - 'ScoreStorage': 'info' + 'DataKernel': 'warn' } }; @@ -45,6 +43,7 @@ this.debug = this.debug.bind(this); this.overrideConsole(); + Promise.resolve().then(() => this.hydrateConfig()); // Output initialization message this.internalLog('info', 'Logger initialized', { @@ -54,41 +53,47 @@ } /** - * Load configuration from localStorage or use defaults + * Build configuration from defaults and explicit bootstrap overrides. */ loadConfig(externalConfig) { - let storedConfig = {}; - try { - const stored = global.localStorage.getItem(STORAGE_KEY); - if (stored) { - storedConfig = JSON.parse(stored); - } - } catch (e) { - // Ignore storage errors - } - return { - level: externalConfig.level || storedConfig.level || DEFAULT_CONFIG.level, + level: externalConfig.level || DEFAULT_CONFIG.level, categories: { ...DEFAULT_CONFIG.categories, - ...(storedConfig.categories || {}), ...(externalConfig.categories || {}) } }; } + async hydrateConfig() { + try { + if (!global.AppData) return; + await global.AppData.ready; + const storedConfig = await global.AppData.preferences.getLogConfig(); + if (!storedConfig || typeof storedConfig !== 'object') return; + this.config = { + level: storedConfig.level || this.config.level, + categories: { ...this.config.categories, ...(storedConfig.categories || {}) } + }; + } catch (error) { + this.nativeConsole.warn('[AppLogger] 无法读取日志配置:', error); + } + } + /** - * Save current configuration to localStorage + * Save current configuration through the preferences domain. */ saveConfig() { - try { - global.localStorage.setItem(STORAGE_KEY, JSON.stringify({ + if (!global.AppData) return Promise.resolve(false); + return global.AppData.ready.then(() => + global.AppData.preferences.setLogConfig({ level: this.config.level, categories: this.config.categories - })); - } catch (e) { - // Ignore storage errors - } + }) + ).then(() => true).catch((error) => { + this.nativeConsole.warn('[AppLogger] 无法保存日志配置:', error); + return false; + }); } /** diff --git a/js/utils/markdownExporter.js b/js/utils/markdownExporter.js index 5f1df08d..0fde777f 100644 --- a/js/utils/markdownExporter.js +++ b/js/utils/markdownExporter.js @@ -98,22 +98,11 @@ class MarkdownExporter { } return comparison; } - constructor() { - this.storage = window.storage; - } - async getPracticeRecordsUnified() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - try { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } catch (error) { - console.warn('[MarkdownExporter] 从 PracticeRecordAPI 获取练习记录失败:', error); - return []; - } - } - - return []; + if (!window.AppData || !window.AppData.practice) throw new Error('AppData.practice is unavailable'); + await window.AppData.ready; + const records = await window.AppData.practice.list({ projection: 'full' }); + return Array.isArray(records) ? records : []; } /** @@ -166,30 +155,15 @@ class MarkdownExporter { */ async performExport() { try { - // 尝试从不同的数据源获取记录 let practiceRecords = []; - let examIndex = []; this.updateProgress('正在加载数据...'); // 让出控制权 await new Promise(resolve => setTimeout(resolve, 10)); - // 只使用统一 PracticeRecordAPI 数据 + // 只使用统一 practice domain 数据 practiceRecords = await this.getPracticeRecordsUnified(); - - // examIndex 仍从存储/全局读取 - if (this.storage && typeof this.storage.get === 'function') { - try { - const idx = await this.storage.get('exam_index', []); - examIndex = Array.isArray(idx) ? idx : []; - } catch (_) { - examIndex = []; - } - } - if ((!Array.isArray(examIndex) || examIndex.length === 0) && window.examIndex) { - examIndex = Array.isArray(window.examIndex) ? window.examIndex : []; - } if (practiceRecords.length === 0) { throw new Error('没有练习记录可导出'); @@ -224,7 +198,7 @@ class MarkdownExporter { await new Promise(resolve => setTimeout(resolve, 10)); // 按日期分组记录 - const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords, examIndex); + const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords); // 生成 Markdown 内容 const markdownContent = await this.generateMarkdownContentAsync(recordsByDate); @@ -280,7 +254,27 @@ class MarkdownExporter { /** * 异步按日期分组记录 */ - async groupRecordsByDateAsync(practiceRecords, examIndex) { + async resolveExamForRecord(record) { + if (typeof window.resolveExamForPracticeRecord !== 'function') { + return null; + } + return window.resolveExamForPracticeRecord(record); + } + + enhanceRecordForExport(record, exam = null) { + const metadata = record && record.metadata && typeof record.metadata === 'object' + ? record.metadata + : {}; + return { + ...record, + examInfo: exam || {}, + title: record.title || metadata.examTitle || exam?.title || '未知题目', + category: record.category || metadata.category || exam?.category || 'Unknown', + frequency: record.frequency || metadata.frequency || exam?.frequency || 'unknown' + }; + } + + async groupRecordsByDateAsync(practiceRecords) { const grouped = {}; for (let i = 0; i < practiceRecords.length; i++) { @@ -293,15 +287,8 @@ class MarkdownExporter { grouped[date] = []; } - // 获取考试信息 - const exam = examIndex.find(e => e.id === record.examId); - const enhancedRecord = { - ...record, - examInfo: exam || {}, - title: exam?.title || record.title || '未知题目', - category: exam?.category || record.category || 'Unknown', - frequency: exam?.frequency || record.frequency || 'unknown' - }; + const exam = await this.resolveExamForRecord(record); + const enhancedRecord = this.enhanceRecordForExport(record, exam); grouped[date].push(enhancedRecord); @@ -317,7 +304,7 @@ class MarkdownExporter { /** * 按日期分组记录(同步版本,保持兼容性) */ - groupRecordsByDate(practiceRecords, examIndex) { + groupRecordsByDate(practiceRecords) { const grouped = {}; practiceRecords.forEach(record => { @@ -328,15 +315,7 @@ class MarkdownExporter { grouped[date] = []; } - // 获取考试信息 - const exam = examIndex.find(e => e.id === record.examId); - const enhancedRecord = { - ...record, - examInfo: exam || {}, - title: exam?.title || record.title || '未知题目', - category: exam?.category || record.category || 'Unknown', - frequency: exam?.frequency || record.frequency || 'unknown' - }; + const enhancedRecord = this.enhanceRecordForExport(record); grouped[date].push(enhancedRecord); }); diff --git a/js/views/legacyViewBundle.js b/js/views/legacyViewBundle.js index 271dadb7..c9758812 100644 --- a/js/views/legacyViewBundle.js +++ b/js/views/legacyViewBundle.js @@ -230,12 +230,11 @@ if (!record) { return false; } - var exam = index.find(function (item) { - return item && (item.id === record.examId || item.title === record.title); - }); - var examType = exam ? normalizeTypeValue(exam.type) : ''; - if (examType) { - return examType === targetType; + var suiteEntries = ensureArray(record.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some(function (entry) { + return normalizeTypeValue(entry && entry.type) === targetType; + }); } var recordType = normalizeTypeValue( record.type || @@ -246,6 +245,13 @@ if (recordType) { return recordType === targetType; } + var exam = index.find(function (item) { + return item && (item.id === record.examId || item.title === record.title); + }); + var examType = exam ? normalizeTypeValue(exam.type) : ''; + if (examType) { + return examType === targetType; + } // 无法确定类型时保持展示,避免题库切换导致历史记录被过滤掉 return true; }); @@ -296,7 +302,9 @@ if (typeof value !== 'number' || isNaN(value)) { return '0.0%'; } - return value.toFixed(1) + '%'; + // Practice-record summary UI: keep a single decimal place so + // correct/total ratios do not dump long floating tails into the list. + return (Math.round(value * 10) / 10).toFixed(1) + '%'; } function formatMinutes(minutes) { @@ -878,6 +886,21 @@ return used; } + function addProjectedErrorCounts(counts, projectedCounts) { + if (!projectedCounts || typeof projectedCounts !== 'object') { + return false; + } + var used = false; + Object.keys(projectedCounts).forEach(function addProjected(type) { + var value = Math.max(0, Number(projectedCounts[type]) || 0); + if (value > 0) { + addRadarCount(counts, type, value); + used = true; + } + }); + return used; + } + function addDetailCounts(counts, record) { var questionTypeMap = buildReadingQuestionTypeMap(record); var sources = getDetailSources(record); @@ -904,7 +927,24 @@ function calculateReadingRadarData(records) { var counts = {}; - var recentReadingRecords = ensureArray(records) + var radarCandidates = []; + ensureArray(records).forEach(function expandSuiteRecord(record) { + var suiteEntries = ensureArray(record && record.suiteEntrySummaries); + if (suiteEntries.length) { + suiteEntries.forEach(function addSuiteEntry(entry) { + if (!entry) { + return; + } + radarCandidates.push(Object.assign({}, entry, { + metadata: Object.assign({}, entry.metadata || {}, { type: entry.type }), + date: entry.date || (record && record.date) + })); + }); + return; + } + radarCandidates.push(record); + }); + var recentReadingRecords = radarCandidates .filter(function filterReading(record) { var metadata = record && record.metadata ? record.metadata : {}; var realData = record && record.realData ? record.realData : {}; @@ -924,6 +964,9 @@ .slice(0, 10); recentReadingRecords.forEach(function collectRecord(record) { + if (addProjectedErrorCounts(counts, record && record.questionTypeErrorCounts)) { + return; + } var performanceMap = record && (record.questionTypePerformance || (record.realData && record.realData.questionTypePerformance)); if (addPerformanceCounts(counts, performanceMap)) { @@ -1547,31 +1590,24 @@ // 练习洞察卡片选中的组件(热力图 / 中高频余量 / 阅读雷达)持久化, // 刷新或重开页面后沿用用户上次的选中组件,而不是总回到默认的热力图。 - var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget'; var SUPPORTED_PRACTICE_WIDGETS = ['heatmap', 'priority', 'radar']; + var persistedPracticeWidget = null; + if (window.AppData && window.AppData.preferences) { + window.AppData.ready.then(function () { return window.AppData.preferences.getPracticeWidget(); }).then(function (value) { + persistedPracticeWidget = SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null; + }).catch(function () {}); + } function loadPersistedPracticeWidget() { - try { - if (typeof localStorage === 'undefined' || !localStorage) { - return null; - } - var value = localStorage.getItem(PRACTICE_WIDGET_PREFERENCE_KEY); - return SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null; - } catch (_) { - return null; - } + return persistedPracticeWidget; } function persistPracticeWidget(widget) { - try { - if (typeof localStorage === 'undefined' || !localStorage) { - return; - } - if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) { - localStorage.setItem(PRACTICE_WIDGET_PREFERENCE_KEY, widget); - } - } catch (_) { - /* 持久化失败不影响渲染 */ + if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) { + persistedPracticeWidget = widget; + window.AppData.preferences.setPracticeWidget(widget).catch(function (error) { + console.warn('[PracticeWidget] 保存失败:', error); + }); } } @@ -2262,7 +2298,10 @@ var durationInSeconds = Number(record && record.duration) || 0; var percentage = typeof record.percentage === 'number' ? record.percentage - : Math.round((record.accuracy || 0) * 100); + : ((Number(record.accuracy) || 0) * 100); + if (!Number.isFinite(percentage)) { + percentage = 0; + } var recordId = ''; if (record && record.id != null) { @@ -2334,7 +2373,7 @@ createNode('div', { className: 'record-percentage', style: { color: helpers.getScoreColor(percentage) } - }, percentage + '%') + }, formatPercentage(percentage)) ]); var actions = null; @@ -3207,10 +3246,125 @@ }; } + var browseCompletionIndex = { + byExamId: new Map(), + byTitle: new Map(), + records: [], + ready: false + }; + + function rememberCompletionCandidate(map, key, candidate) { + if (!map || !key || !candidate) { + return; + } + var existing = map.get(key); + if (!existing || candidate.timestamp > existing.timestamp) { + map.set(key, candidate); + } + } + + /** + * 在 setPracticeRecords 时重建一次正确率索引。 + * 不使用 version 计数器;生命周期绑定“写状态那一次”。 + */ + function resolveRecordExamId(record) { + if (!record || typeof record !== 'object') { + return ''; + } + var metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {}; + var realData = record.realData && typeof record.realData === 'object' ? record.realData : {}; + var rawData = record.rawData && typeof record.rawData === 'object' ? record.rawData : {}; + return record.examId || metadata.examId || realData.examId || rawData.examId || ''; + } + + function getBrowseSuiteEntries(record) { + if (!record || typeof record !== 'object') { + return []; + } + var summaries = Array.isArray(record.suiteEntrySummaries) ? record.suiteEntrySummaries : []; + if (summaries.length) { + return summaries; + } + return Array.isArray(record.suiteEntries) ? record.suiteEntries : []; + } + + function rebuildBrowseCompletionIndex(records) { + var byExamId = new Map(); + var byTitle = new Map(); + var recordSnapshot = ensureArray(records).slice(); + recordSnapshot.forEach(function indexRecord(record) { + if (!record || typeof record !== 'object') { + return; + } + var candidate = buildCompletionStatusCandidate(record); + var recordExamId = resolveRecordExamId(record); + if (recordExamId) { + rememberCompletionCandidate(byExamId, String(recordExamId), candidate); + } + var recordTitle = record.title || record.examTitle || (record.metadata && record.metadata.examTitle) || ''; + if (recordTitle) { + rememberCompletionCandidate(byTitle, String(recordTitle), candidate); + } + var suiteEntries = getBrowseSuiteEntries(record); + suiteEntries.forEach(function indexSuiteEntry(entry) { + if (!entry || typeof entry !== 'object') { + return; + } + var comparableEntry = buildComparableSuiteEntryRecord(record, entry); + var entryCandidate = buildCompletionStatusCandidate(comparableEntry, record); + var entryExamId = resolveRecordExamId(comparableEntry); + if (entryExamId) { + rememberCompletionCandidate(byExamId, String(entryExamId), entryCandidate); + } + var entryTitle = comparableEntry.title || comparableEntry.examTitle || ''; + if (entryTitle) { + rememberCompletionCandidate(byTitle, String(entryTitle), entryCandidate); + } + }); + }); + browseCompletionIndex = { + byExamId: byExamId, + byTitle: byTitle, + records: recordSnapshot, + ready: true + }; + return browseCompletionIndex; + } + + function ensureBrowseCompletionIndex() { + if (browseCompletionIndex.ready) { + return browseCompletionIndex; + } + return browseCompletionIndex; + } + LegacyExamListView.prototype._getCompletionStatus = function _getCompletionStatus(exam) { - var source = (typeof global.getPracticeRecordsState === 'function') - ? global.getPracticeRecordsState() - : global.practiceRecords; + var index = ensureBrowseCompletionIndex(); + var byId = null; + var byTitle = null; + if (exam && exam.id && index.byExamId.has(String(exam.id))) { + byId = index.byExamId.get(String(exam.id)); + } + if (exam && exam.title && index.byTitle.has(String(exam.title))) { + byTitle = index.byTitle.get(String(exam.title)); + } + // 同时有 examId / title 命中时取较新时间戳,避免旧 examId 遮蔽更新 title 匹配。 + var indexed = null; + if (byId && byTitle) { + indexed = (Number(byId.timestamp) || 0) >= (Number(byTitle.timestamp) || 0) ? byId : byTitle; + } else { + indexed = byId || byTitle; + } + if (indexed) { + return { + percentage: typeof indexed.percentage === 'number' ? indexed.percentage : 0, + date: indexed.date || null, + duration: typeof indexed.duration === 'number' ? indexed.duration : 0 + }; + } + + // Path/file fallback scans the same authoritative snapshot used to build the index. + var source = index.records; var statuses = []; ensureArray(source).forEach(function collectStatus(record) { if (!record || typeof record !== 'object') { @@ -3219,7 +3373,7 @@ if (recordMatchesExam(exam, record)) { statuses.push(buildCompletionStatusCandidate(record)); } - var suiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : []; + var suiteEntries = getBrowseSuiteEntries(record); suiteEntries.forEach(function collectSuiteEntry(entry) { if (!entry || typeof entry !== 'object') { return; @@ -3244,6 +3398,8 @@ }; }; + global.rebuildBrowseCompletionIndex = rebuildBrowseCompletionIndex; + // --- Legacy navigation controller --- function LegacyNavigationController(options) { options = options || {}; @@ -3522,8 +3678,8 @@ }; LibraryConfigView.prototype._renderItem = function _renderItem(config, activeKey, allowDelete) { - var isActive = activeKey === config.key; - var isDefault = config.key === 'exam_index'; + var isDefault = config.builtIn === true; + var isActive = isDefault ? activeKey == null : activeKey === config.key; var className = this.classNames.item + (isActive ? ' ' + this.classNames.itemActive : ''); var item = this._createElement('div', { @@ -3551,7 +3707,7 @@ type: 'button', dataset: { configAction: 'switch', - configKey: config.key, + configKey: config.key || '', configActive: isActive ? '1' : '0' } }, '切换'); @@ -3578,7 +3734,7 @@ type: 'button', dataset: { configAction: 'delete', - configKey: config.key, + configKey: config.key || '', configActive: isActive ? '1' : '0' } }, '删除'); From 4d9b4f74311702f2471b780915fa813703c967f8 Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:34:26 +0800 Subject: [PATCH 11/18] feat(app): align backup, library, browse, and settings flows --- css/main.css | 9 +- index.html | 5 +- js/app/browseController.js | 110 +- js/components/BrowseStateManager.js | 22 +- js/components/PerformanceOptimizer.js | 1 + js/components/onboardingTour.js | 305 +++-- js/components/vocabSessionView.js | 98 +- js/core/externalBackupService.js | 1749 ++++++++++--------------- js/presentation/indexInteractions.js | 90 +- js/presentation/threeBackground.js | 22 +- js/services/libraryManager.js | 283 ++-- js/theme-switcher.js | 75 +- js/utils/BrowsePreferencesUtils.js | 112 +- templates/exam-placeholder.html | 148 ++- templates/template_base.html | 157 ++- 15 files changed, 1515 insertions(+), 1671 deletions(-) diff --git a/css/main.css b/css/main.css index 6b83746c..61a69d4a 100644 --- a/css/main.css +++ b/css/main.css @@ -4505,9 +4505,14 @@ body.blue-dark-mode .theme-modal-close:hover { background: rgba(28, 28, 28, 0.45); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 100%); - mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 100%); + /* 末段在 92%→100% 渐隐为透明,使深色 glass 在贴到卡片圆角之前淡出, + 避免在卡片底边留下直边 silhouette 把父级 18px 圆角撑成方角。 */ + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 92%, transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 92%, transparent 100%); color: #fff; + /* 自身底角圆角与 .theme-card 的 18px 对齐,让 glass layer 不再用直边顶到父级圆角处。 */ + border-radius: 0 0 18px 18px; + overflow: hidden; } .theme-card-header { diff --git a/index.html b/index.html index b8d457f4..7a775908 100644 --- a/index.html +++ b/index.html @@ -784,10 +784,11 @@

🔧 系统管理

系统工具和设置选项

- - - - -
- -
-

测试结果:

-
-
- -
-

当前状态:

-
无状态数据
-
- - - - - - -`; - - return testPage; - } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = StateSerializerTest; -} \ No newline at end of file diff --git a/developer/tests/js/storageManagerRecords.test.js b/developer/tests/js/storageManagerRecords.test.js deleted file mode 100644 index e2caaad4..00000000 --- a/developer/tests/js/storageManagerRecords.test.js +++ /dev/null @@ -1,629 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function createMemoryStorage() { - const data = new Map(); - return { - get length() { - return data.size; - }, - key(index) { - return Array.from(data.keys())[index] || null; - }, - getItem(key) { - return data.has(String(key)) ? data.get(String(key)) : null; - }, - setItem(key, value) { - data.set(String(key), String(value)); - }, - removeItem(key) { - data.delete(String(key)); - }, - clear() { - data.clear(); - }, - _dump() { - return Object.fromEntries(data.entries()); - } - }; -} - -function createFetch(responseData = null) { - return async () => ({ - ok: true, - async json() { - return responseData || { practice_records: [{ id: 'backup-record', examId: 'reading-backup' }] }; - } - }); -} - -async function createHarness(options = {}) { - const localStorage = createMemoryStorage(); - const sessionStorage = createMemoryStorage(); - const intervals = []; - const timeouts = []; - const listeners = []; - const calls = []; - const quietConsole = { - log() {}, - info() {}, - warn() {}, - error() {} - }; - - const windowStub = { - location: { protocol: options.protocol || 'http:' }, - localStorage, - sessionStorage, - indexedDB: null, - dispatchEvent() {}, - addEventListener(type, handler) { - listeners.push({ type, handler }); - }, - showMessage() {}, - fetch: createFetch(options.backupData), - setTimeout(callback, delay) { - const id = { callback, delay }; - timeouts.push(id); - return id; - }, - clearTimeout(id) { - const index = timeouts.indexOf(id); - if (index >= 0) { - timeouts.splice(index, 1); - } - } - }; - if (options.practiceRecordAPI) { - windowStub.PracticeRecordAPI = options.practiceRecordAPI(calls); - } - - const sandbox = { - window: windowStub, - globalThis: windowStub, - localStorage, - sessionStorage, - document: { - dispatchEvent() {} - }, - CustomEvent: class CustomEvent { - constructor(type, init = {}) { - this.type = type; - this.detail = init.detail || null; - } - }, - console: quietConsole, - JSON, - Date, - Math, - setInterval(callback, delay) { - const id = { callback, delay }; - intervals.push(id); - return id; - }, - clearInterval(id) { - const index = intervals.indexOf(id); - if (index >= 0) { - intervals.splice(index, 1); - } - }, - setTimeout(callback, delay) { - return windowStub.setTimeout(callback, delay); - }, - clearTimeout(id) { - return windowStub.clearTimeout(id); - }, - fetch: createFetch(options.backupData) - }; - const context = vm.createContext(sandbox); - const source = fs.readFileSync(path.join(repoRoot, 'js/utils/storage.js'), 'utf8'); - vm.runInContext(source, context, { filename: 'js/utils/storage.js' }); - await windowStub.persistentStore.ready; - return { - window: windowStub, - persistentStore: windowStub.persistentStore, - localStorage, - sessionStorage, - calls, - intervals, - timeouts, - context, - listeners - }; -} - -function loadScript(relativePath, context) { - const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function flushWindowTimeouts(harness) { - while (harness.timeouts.length > 0) { - const pending = harness.timeouts.splice(0, harness.timeouts.length); - pending.forEach((entry) => { - if (typeof entry.callback === 'function') { - entry.callback(); - } - }); - } -} - -function readEnvelope(storage, key) { - const raw = storage.getItem(key); - return raw ? JSON.parse(raw).data : null; -} - -function readRawPracticeRecords(harness) { - const records = readEnvelope(harness.localStorage, 'exam_system_practice_records'); - return Array.isArray(records) ? records : []; -} - -async function writeRawPracticeRecords(harness, records) { - harness.localStorage.setItem('exam_system_practice_records', JSON.stringify({ - data: records, - timestamp: Date.now(), - version: '0.6.2-fix', - compressed: false - })); -} - -async function testStorageDataSourceReadBypassesPublicPracticeRecordRedirect() { - const calls = []; - const windowStub = { ExamData: {} }; - const sandbox = { - window: windowStub, - console: { - log() {}, - info() {}, - warn() {}, - error() {} - } - }; - const context = vm.createContext(sandbox); - const source = fs.readFileSync(path.join(repoRoot, 'js/data/dataSources/storageDataSource.js'), 'utf8'); - vm.runInContext(source, context, { filename: 'js/data/dataSources/storageDataSource.js' }); - - const storageManager = { - async get(key, defaultValue, options = {}) { - calls.push({ type: 'get', key, options }); - if (key === 'practice_records' && !options.skipPracticeCoreRedirect) { - throw new Error('public practice record redirect would recurse'); - } - return key === 'practice_records' - ? [{ id: `raw-${calls.length}`, examId: 'reading-raw' }] - : defaultValue; - }, - async set() { - return true; - }, - async remove() { - return true; - } - }; - - const externalDataSource = new windowStub.ExamData.StorageDataSource(storageManager); - await assert.rejects( - () => externalDataSource.read('practice_records', []), - /protected key practice_records/, - '外部 new StorageDataSource 不能读取 protected key' - ); - - const dataSource = new windowStub.ExamData.StorageDataSource(storageManager, { - createInternalOptions() { - return { skipPracticeCoreRedirect: true, internalAccessToken: Symbol('test-internal') }; - } - }); - const records = await dataSource.read('practice_records', []); - assert.strictEqual(records[0].id, 'raw-1', 'StorageDataSource.read 应直接读底层 raw store'); - - const txRecords = await dataSource.runTransaction(async (transaction) => { - return transaction.get('practice_records', []); - }); - assert.strictEqual(txRecords[0].id, 'raw-2', 'StorageTransactionContext.get 应直接读底层 raw store'); - - assert.strictEqual(calls.length, 2, 'read 和 transaction.get 应各触发一次底层读取'); - assert(calls.every((call) => call.options && call.options.skipPracticeCoreRedirect === true), - 'StorageDataSource 底层读取必须跳过 PracticeRecordAPI/public storage redirect'); -} - -async function testRuntimeImportFailsWithoutPracticeRecordAPI() { - const harness = await createHarness(); - await writeRawPracticeRecords(harness, [{ id: 'existing-record', examId: 'reading-existing' }]); - - const result = await harness.persistentStore.importData({ - data: { - practice_records: [{ id: 'import-record', examId: 'reading-import' }] - } - }); - - assert.strictEqual(result.success, false, '运行期导入缺少 PracticeRecordAPI 时必须失败'); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'import-record'), - false, - '运行期导入失败不能把导入记录写入 raw practice_records' - ); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'existing-record'), - true, - '统一 API 缺失时导入必须 fail-fast,不能先清空已有练习记录' - ); -} - -async function testInternalStorageAccessIsNotWindowPublic() { - const harness = await createHarness(); - flushWindowTimeouts(harness); - - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window, 'createStorageInternalAccessOptions'), - false, - 'internal storage access 生成器不能挂到 window' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window, 'hasStorageInternalAccess'), - false, - 'internal storage access 校验器不能挂到 window' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window, '__installStorageInternalAccess'), - true, - 'storage internal access installer 在被消费前应保持可用' - ); -} - -async function testFullDataBootstrapHidesInternalPracticeRepositories() { - const harness = await createHarness(); - const scripts = [ - 'js/core/storageProviderRegistry.js', - 'js/data/dataSources/storageDataSource.js', - 'js/data/repositories/baseRepository.js', - 'js/data/repositories/dataRepositoryRegistry.js', - 'js/data/repositories/practiceRepository.js', - 'js/data/repositories/settingsRepository.js', - 'js/data/repositories/backupRepository.js', - 'js/data/repositories/metaRepository.js', - 'js/core/practiceCore.js', - 'js/data/index.js', - 'js/core/practiceRecordAPI.js' - ]; - scripts.forEach((script) => loadScript(script, harness.context)); - flushWindowTimeouts(harness); - - assert(harness.window.PracticeRecordAPI, 'PracticeRecordAPI 应完成初始化'); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window.ExamData, 'internalRepositories'), - false, - 'ExamData.internalRepositories 不能暴露底层 practiceRepo' - ); - assert.strictEqual( - Boolean(harness.window.dataRepositories && harness.window.dataRepositories.practice), - false, - 'public dataRepositories 不能暴露 practice 仓库' - ); - assert.strictEqual( - typeof harness.window.PracticeCore.__installInternalRepositories, - 'undefined', - 'PracticeCore 内部仓库 installer 必须在数据层注入后删除' - ); - assert.strictEqual( - typeof harness.window.PracticeCore.__installRecordAPI, - 'undefined', - 'PracticeCore RecordAPI installer 必须在 PracticeRecordAPI 初始化后删除' - ); - assert.strictEqual( - typeof harness.window.__installStorageInternalAccess, - 'undefined', - 'storage internal access installer 必须在数据层注入后删除' - ); - - await harness.window.PracticeRecordAPI.saveRecord({ - id: 'bootstrap-record', - examId: 'reading-bootstrap', - type: 'reading', - date: '2026-05-25T00:00:00.000Z', - score: 1, - totalQuestions: 1, - correctAnswers: 1, - accuracy: 1 - }); - const records = await harness.window.PracticeRecordAPI.list(); - assert.strictEqual(records.length, 1, '隐藏内部仓库后 PracticeRecordAPI 仍应能落库'); - assert.strictEqual(records[0].id, 'bootstrap-record'); -} - -async function testRuntimeImportUsesPracticeRecordAPI() { - const savedRecords = []; - const harness = await createHarness({ - practiceRecordAPI: (calls) => ({ - async replace(records) { - calls.push({ type: 'api.replace', records }); - savedRecords.splice(0, savedRecords.length, ...(Array.isArray(records) ? records : [])); - return savedRecords.slice(); - }, - async list() { - calls.push({ type: 'api.list' }); - return savedRecords.slice(); - } - }) - }); - - const result = await harness.persistentStore.importData({ - data: { - practice_records: [{ id: 'import-record', examId: 'reading-import' }] - } - }); - - assert.strictEqual(result.success, true, 'PracticeRecordAPI 可用时运行期导入应成功'); - assert(harness.calls.some((call) => call.type === 'api.replace'), '运行期导入必须调用 PracticeRecordAPI.replace'); - assert.strictEqual(savedRecords.length, 1, 'PracticeRecordAPI 应收到导入记录'); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'import-record'), - false, - '运行期导入成功也不能把导入记录落到 raw practice_records 影子键' - ); -} - -async function testPublicStorageFacadeReadsWithPracticeRecordAPIAndRejectsWrites() { - const savedRecords = [{ id: 'api-existing', examId: 'reading-existing' }]; - let stats = { totalPractices: 1 }; - const harness = await createHarness({ - practiceRecordAPI: (calls) => ({ - async list() { - calls.push({ type: 'api.list' }); - return savedRecords.slice(); - }, - async replace(records, options) { - calls.push({ type: 'api.replace', records, options }); - throw new Error('public storage facade must not call PracticeRecordAPI.replace'); - }, - async clear(options) { - calls.push({ type: 'api.clear', options }); - throw new Error('public storage facade must not call PracticeRecordAPI.clear'); - }, - async readStats(options = {}) { - calls.push({ type: 'api.readStats', options }); - return Object.assign({}, options.fallback || {}, stats); - }, - async writeStats(nextStats) { - calls.push({ type: 'api.writeStats', stats: nextStats }); - throw new Error('public storage facade must not call PracticeRecordAPI.writeStats'); - }, - async resetStats() { - calls.push({ type: 'api.resetStats' }); - throw new Error('public storage facade must not call PracticeRecordAPI.resetStats'); - } - }) - }); - - const listed = await harness.persistentStore.get('practice_records', []); - assert.strictEqual(listed.length, 1, 'public storage.get(practice_records) 应委托 PracticeRecordAPI.list'); - - await assert.rejects( - () => harness.persistentStore.set('practice_records', [{ id: 'api-next', examId: 'reading-next' }]), - /Storage\.set\(practice_records\) is disabled/, - 'public storage.set(practice_records) 必须禁用' - ); - assert.strictEqual(savedRecords[0].id, 'api-existing', 'public storage.set(practice_records) 不能改 canonical records'); - assert.strictEqual(readRawPracticeRecords(harness).some((record) => record && record.id === 'api-next'), false, 'public storage.set(practice_records) 不能写 raw shadow key'); - - await assert.rejects( - () => harness.persistentStore.remove('practice_records'), - /Storage\.remove\(practice_records\) is disabled/, - 'public storage.remove(practice_records) 必须禁用' - ); - assert.strictEqual(savedRecords.length, 1, 'public storage.remove(practice_records) 不能清空 canonical records'); - - const readStats = await harness.persistentStore.get('user_stats', { totalPractices: 0 }); - assert.strictEqual(readStats.totalPractices, 1, 'public storage.get(user_stats) 应委托 PracticeRecordAPI.readStats'); - - await assert.rejects( - () => harness.persistentStore.set('user_stats', { totalPractices: 3 }), - /Storage\.set\(user_stats\) is disabled/, - 'public storage.set(user_stats) 必须禁用' - ); - assert.strictEqual(stats.totalPractices, 1, 'public storage.set(user_stats) 不能改 canonical stats'); - - await assert.rejects( - () => harness.persistentStore.remove('user_stats'), - /Storage\.remove\(user_stats\) is disabled/, - 'public storage.remove(user_stats) 必须禁用' - ); - assert.strictEqual(stats.totalPractices, 1, 'public storage.remove(user_stats) 不能重置 canonical stats'); - - const callTypes = harness.calls.map((call) => call.type); - assert(callTypes.includes('api.list'), 'PracticeRecordAPI.list must be called'); - assert(callTypes.includes('api.readStats'), 'PracticeRecordAPI.readStats must be called'); - assert(!callTypes.includes('api.replace'), 'public storage.set(practice_records) must not call PracticeRecordAPI.replace'); - assert(!callTypes.includes('api.clear'), 'public storage.remove(practice_records) must not call PracticeRecordAPI.clear'); - assert(!callTypes.includes('api.writeStats'), 'public storage.set(user_stats) must not call PracticeRecordAPI.writeStats'); - assert(!callTypes.includes('api.resetStats'), 'public storage.remove(user_stats) must not call PracticeRecordAPI.resetStats'); -} - -async function testPublicBypassOptionsCannotWritePracticeData() { - const savedRecords = [{ id: 'api-existing', examId: 'reading-existing' }]; - let stats = { totalPractices: 1 }; - const harness = await createHarness({ - practiceRecordAPI: (calls) => ({ - async list() { - calls.push({ type: 'api.list' }); - return savedRecords.slice(); - }, - async replace(records, options) { - calls.push({ type: 'api.replace', records, options }); - throw new Error('public bypass options must not call PracticeRecordAPI.replace'); - }, - async clear(options) { - calls.push({ type: 'api.clear', options }); - throw new Error('public bypass options must not call PracticeRecordAPI.clear'); - }, - async readStats(options = {}) { - calls.push({ type: 'api.readStats', options }); - return Object.assign({}, options.fallback || {}, stats); - }, - async writeStats(nextStats) { - calls.push({ type: 'api.writeStats', stats: nextStats }); - throw new Error('public bypass options must not call PracticeRecordAPI.writeStats'); - }, - async resetStats() { - calls.push({ type: 'api.resetStats' }); - throw new Error('public bypass options must not call PracticeRecordAPI.resetStats'); - } - }) - }); - await writeRawPracticeRecords(harness, [{ id: 'raw-shadow', examId: 'reading-shadow' }]); - - const listed = await harness.persistentStore.get('practice_records', [], { skipPracticeCoreRedirect: true }); - assert.strictEqual(listed[0].id, 'api-existing', 'skipPracticeCoreRedirect 不能让 public get 读取 raw practice_records'); - - await assert.rejects( - () => harness.persistentStore.set('practice_records', [{ id: 'api-skip', examId: 'reading-skip' }], { skipPracticeCoreRedirect: true }), - /Storage\.set\(practice_records\) is disabled/, - 'skipPracticeCoreRedirect 不能让 public set 写 practice_records' - ); - assert.strictEqual(savedRecords[0].id, 'api-existing', 'skipPracticeCoreRedirect public set 不能改 canonical records'); - assert.strictEqual(readRawPracticeRecords(harness).some((record) => record && record.id === 'api-skip'), false, - 'skipPracticeCoreRedirect public set 不能写 raw practice_records'); - - await assert.rejects( - () => harness.persistentStore.set('user_stats', { totalPractices: 5 }, { skipReady: true }), - /Storage\.set\(user_stats\) is disabled/, - 'skipReady 不能让 public set(user_stats) 写 stats' - ); - assert.strictEqual(stats.totalPractices, 1, 'skipReady public set(user_stats) 不能改 canonical stats'); - - await assert.rejects( - () => harness.persistentStore.append('practice_records', { id: 'api-append', examId: 'reading-append' }), - /Storage\.append\(practice_records\) is disabled/, - 'public append(practice_records) 必须禁用' - ); - - await assert.rejects( - () => harness.persistentStore.remove('practice_records', { skipReady: true }), - /Storage\.remove\(practice_records\) is disabled/, - 'skipReady 不能让 public remove(practice_records) 清空 records' - ); - assert.strictEqual(savedRecords.length, 1, 'skipReady public remove(practice_records) 不能清空 canonical records'); - - const callTypes = harness.calls.map((call) => call.type); - assert(!callTypes.includes('api.replace'), 'public bypass options must not call PracticeRecordAPI.replace'); - assert(!callTypes.includes('api.clear'), 'public bypass options must not call PracticeRecordAPI.clear'); - assert(!callTypes.includes('api.writeStats'), 'public bypass options must not call PracticeRecordAPI.writeStats'); - assert(!callTypes.includes('api.resetStats'), 'public bypass options must not call PracticeRecordAPI.resetStats'); -} - -async function testCompressedRealDataKeepsOnlyCanonicalCorrectAnswerMap() { - const harness = await createHarness(); - const compressed = harness.persistentStore.compressRealData({ - score: 1, - totalQuestions: 2, - accuracy: 0.5, - percentage: 50, - duration: 30, - answers: { q1: 'A' }, - correctAnswerMap: { q1: 'A' }, - correctAnswers: { q1: 'B' }, - answerComparison: { - q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false } - }, - isRealData: true, - source: 'test' - }); - - assert.deepStrictEqual(compressed.correctAnswerMap, { q1: 'A' }, '压缩 realData 只能保留 canonical correctAnswerMap'); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(compressed, 'correctAnswers'), - false, - '压缩 realData 不能保留 legacy correctAnswers 对象' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(compressed.answerComparison.q1, 'correctAnswer'), - false, - '压缩 answerComparison 不能保留 correctAnswer 作为第二事实源' - ); - assert.strictEqual(compressed.answerComparison.q1.isCorrect, false, '压缩 comparison 应保留已有正误显示结果'); -} - -async function testPublicStorageFacadeDoesNotFallbackToPracticeCore() { - const harness = await createHarness(); - harness.window.PracticeCore = { - store: { - handlesStorageKey(key) { - return key === 'practice_records' || key === 'user_stats'; - }, - async routeStorageSet() { - throw new Error('public storage facade must not fallback to PracticeCore.store routeStorageSet'); - }, - async routeStorageRemove() { - throw new Error('public storage facade must not fallback to PracticeCore.store routeStorageRemove'); - } - } - }; - - await assert.rejects( - () => harness.persistentStore.get('practice_records', []), - /Storage\.get\(practice_records\): PracticeRecordAPI\.list not ready/, - 'API 缺失时 public storage.get(practice_records) 必须失败,不能返回默认值或读 raw store' - ); - - await assert.rejects( - () => harness.persistentStore.set('practice_records', [{ id: 'must-not-write' }]), - /Storage\.set\(practice_records\) is disabled/, - 'API 缺失时 public storage.set(practice_records) 必须失败' - ); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'must-not-write'), - false, - 'API 缺失时 public storage.set(practice_records) 不能写 raw store' - ); - - await assert.rejects( - () => harness.persistentStore.remove('practice_records'), - /Storage\.remove\(practice_records\) is disabled/, - 'API 缺失时 public storage.remove(practice_records) 必须失败' - ); -} - -async function testRestoreFromBackupFailsWithoutPracticeRecordAPI() { - const harness = await createHarness(); - const result = await harness.persistentStore.restoreFromBackup(); - - assert.strictEqual(result, false, '内置备份恢复缺少 PracticeRecordAPI 时应跳过失败'); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'backup-record'), - false, - '内置备份恢复失败不能把备份记录写入 raw practice_records' - ); -} - -async function main() { - await testStorageDataSourceReadBypassesPublicPracticeRecordRedirect(); - await testRuntimeImportFailsWithoutPracticeRecordAPI(); - await testInternalStorageAccessIsNotWindowPublic(); - await testFullDataBootstrapHidesInternalPracticeRepositories(); - await testRuntimeImportUsesPracticeRecordAPI(); - await testPublicStorageFacadeReadsWithPracticeRecordAPIAndRejectsWrites(); - await testPublicBypassOptionsCannotWritePracticeData(); - await testCompressedRealDataKeepsOnlyCanonicalCorrectAnswerMap(); - await testPublicStorageFacadeDoesNotFallbackToPracticeCore(); - await testRestoreFromBackupFailsWithoutPracticeRecordAPI(); - - process.stdout.write(JSON.stringify({ - status: 'pass', - detail: 'StorageManager 运行期 records/stats 公开读只通过 PracticeRecordAPI,公开写入口 fail-fast' - })); -} - -main().catch((error) => { - process.stdout.write(JSON.stringify({ - status: 'fail', - detail: error && error.message ? error.message : String(error) - })); - process.exit(1); -}); diff --git a/developer/tests/js/suiteInlineFallback.test.js b/developer/tests/js/suiteInlineFallback.test.js index e2fdf51b..7bde504b 100644 --- a/developer/tests/js/suiteInlineFallback.test.js +++ b/developer/tests/js/suiteInlineFallback.test.js @@ -4,6 +4,7 @@ import fs from 'fs'; import vm from 'vm'; import assert from 'assert'; import { fileURLToPath } from 'url'; +import { webcrypto } from 'node:crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -41,6 +42,7 @@ function createExamWindow(parentWindow) { Date, Array, JSON, + URL, }); scriptContext.globalThis = examWindow; examWindow.window = examWindow; @@ -78,6 +80,7 @@ function createExamWindow(parentWindow) { querySelectorAll() { return [buttonStub]; }, + referrer: 'http://localhost/index.html', readyState: 'complete', defaultView: null }; @@ -87,7 +90,7 @@ function createExamWindow(parentWindow) { document: doc, opener: parentWindow, parent: parentWindow, - location: { href: 'http://localhost/p1.html' }, + location: { href: 'http://localhost/p1.html', protocol: 'http:' }, closed: false, _messageListeners: messageListeners, _messages: [], @@ -101,7 +104,7 @@ function createExamWindow(parentWindow) { postMessage(message) { this._messages.push(message); messageListeners.slice().forEach(listener => { - listener({ data: message, source: parentWindow }); + listener({ data: message, source: parentWindow, origin: 'http://localhost' }); }); }, focus() {}, @@ -135,6 +138,7 @@ async function main() { location: { origin: 'http://localhost', href: 'http://localhost/index.html' }, screen: { availWidth: 1920, availHeight: 1080 }, document: { title: 'IELTS Practice' }, + crypto: webcrypto, postMessage(message) { this._messages.push(message); } @@ -151,6 +155,8 @@ async function main() { Date, JSON, Array, + URL, + Uint8Array, }; sandbox.globalThis = sandbox.window; @@ -191,6 +197,13 @@ async function main() { app.suiteExamMap = new Map([[examId, suiteSessionId]]); const examWindow = createExamWindow(parentWindow); + app.examWindows = new Map([[examId, { + window: examWindow, + expectedSessionId: `session-${examId}`, + expectedUrl: examWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); app.injectInlineScript(examWindow, examId); const initMessage = examWindow._messages.find(msg => msg && msg.type === 'INIT_SESSION'); @@ -209,12 +222,32 @@ async function main() { assert.strictEqual(examWindow._nativeCloseCalled, false, '窗口不应真正关闭'); assert.strictEqual(examWindow.closed, false, '窗口状态应保持开启'); - const navigateMessage = { type: 'SUITE_NAVIGATE', data: { url: 'http://localhost/p2.html', examId: 'reading-inline-2' } }; - examWindow._messageListeners.forEach(listener => listener({ data: navigateMessage })); + const navigateMessage = { + type: 'SUITE_NAVIGATE', + source: 'exam_host', + data: { + url: 'http://localhost/p2.html', + examId: 'reading-inline-2', + windowSessionToken: initMessage.data.windowSessionToken + } + }; + examWindow._messageListeners.forEach(listener => listener({ + data: navigateMessage, + source: parentWindow, + origin: 'http://localhost' + })); assert.strictEqual(examWindow.location.href, 'http://localhost/p2.html', '应在标签页内导航至下一篇'); - const forceCloseMessage = { type: 'SUITE_FORCE_CLOSE', data: { suiteSessionId } }; - examWindow._messageListeners.forEach(listener => listener({ data: forceCloseMessage })); + const forceCloseMessage = { + type: 'SUITE_FORCE_CLOSE', + source: 'exam_host', + data: { suiteSessionId, windowSessionToken: initMessage.data.windowSessionToken } + }; + examWindow._messageListeners.forEach(listener => listener({ + data: forceCloseMessage, + source: parentWindow, + origin: 'http://localhost' + })); assert.strictEqual(examWindow._nativeCloseCalled, true, '强制关闭应调用原生 close'); assert.strictEqual(examWindow.closed, true, '强制关闭后窗口应标记为关闭'); diff --git a/developer/tests/js/suiteModeFlow.test.js b/developer/tests/js/suiteModeFlow.test.js index 3a9b70c1..019ee762 100755 --- a/developer/tests/js/suiteModeFlow.test.js +++ b/developer/tests/js/suiteModeFlow.test.js @@ -4,6 +4,7 @@ import fs from 'fs'; import vm from 'vm'; import assert from 'assert'; import { fileURLToPath } from 'url'; +import { webcrypto } from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -23,7 +24,7 @@ function createStubWindow(name) { const stub = { name, closed: false, - location: { href: 'about:blank' }, + location: { href: 'http://localhost/exam.html' }, document: { title: '', addEventListener() {}, removeEventListener() {} }, focus() { this._focused = true; }, close() { this.closed = true; }, @@ -51,19 +52,8 @@ function createStubWindow(name) { } async function main() { - const storageState = new Map(); const practiceRecords = []; - const storage = { - async get(key, fallback = undefined) { - if (storageState.has(key)) { - return deepClone(storageState.get(key)); - } - return deepClone(fallback); - }, - async set(key, value) { - storageState.set(key, deepClone(value)); - } - }; + const windowSessions = new Map(); const documentStub = { title: '', @@ -82,7 +72,8 @@ async function main() { }, addEventListener() {}, removeEventListener() {}, - location: { href: 'http://localhost/' }, + location: { href: 'http://localhost/', origin: 'http://localhost' }, + crypto: webcrypto, screen: { availWidth: 1920, availHeight: 1080 }, document: documentStub, practicePageManager: { @@ -97,57 +88,56 @@ async function main() { } }; - windowStub.storage = storage; - windowStub.PracticeRecordAPI = { - async list() { - return deepClone(practiceRecords); - }, - async saveRecord(record) { - practiceRecords.unshift(deepClone(record)); - return deepClone(record); - }, - async deleteMany(ids) { - const targets = new Set((Array.isArray(ids) ? ids : []).map((id) => String(id))); - let deleted = 0; - for (let index = practiceRecords.length - 1; index >= 0; index -= 1) { - const record = practiceRecords[index]; - if (record && targets.has(String(record.id || record.sessionId || ''))) { - practiceRecords.splice(index, 1); - deleted += 1; + windowStub.resolveActiveLibraryIndex = async () => deepClone(examIndex); + windowStub.AppData = { + ready: Promise.resolve(), + practice: { + async list() { return deepClone(practiceRecords); }, + async getStats() { return { totalPractices: practiceRecords.length }; }, + async finalizeSuite({ record, childSessionIds = [] }) { + const targets = new Set(childSessionIds.map(String)); + for (let index = practiceRecords.length - 1; index >= 0; index -= 1) { + const current = practiceRecords[index]; + if (targets.has(String(current && (current.id || current.sessionId) || ''))) { + practiceRecords.splice(index, 1); + } } + const identity = String(record && (record.id || record.sessionId) || ''); + const existing = practiceRecords.findIndex((item) => String(item && (item.id || item.sessionId) || '') === identity); + if (existing >= 0) practiceRecords[existing] = deepClone(record); + else practiceRecords.unshift(deepClone(record)); + return { committed: true, operationId: `suite-${identity}`, record: deepClone(record), derived: { status: 'ready', pending: [] }, warnings: [] }; } - return { deleted }; }, - async recalculateStats() { - return { totalPractices: practiceRecords.length }; + recovery: { + windowSession: { + save(name, value) { windowSessions.set(String(name), deepClone(value)); return true; }, + get(name) { return deepClone(windowSessions.get(String(name)) || null); }, + discard(name) { windowSessions.delete(String(name)); return true; } + }, + async listDrafts() { return []; }, + async listActiveSessions() { return []; }, + async saveActiveSession() { return { committed: true }; } } }; windowStub.CustomEvent = function CustomEvent(type, init = {}) { return { type, detail: init.detail || null }; }; - const sessionStorageStub = new Map(); - const sessionStorageObj = { - getItem(key) { return sessionStorageStub.get(key) || null; }, - setItem(key, value) { sessionStorageStub.set(key, String(value)); }, - removeItem(key) { sessionStorageStub.delete(key); }, - clear() { sessionStorageStub.clear(); } - }; - const sandbox = { window: windowStub, - storage, console, setTimeout, clearTimeout, setInterval, clearInterval, Math, + crypto: webcrypto, + URL, document: documentStub, CustomEvent: windowStub.CustomEvent }; sandbox.globalThis = sandbox.window; - sandbox.window.sessionStorage = sessionStorageObj; const context = vm.createContext(sandbox); @@ -184,10 +174,6 @@ async function main() { } ]; - await storage.set('exam_index', examIndex); - await storage.set('active_exam_index_key', 'exam_index'); - await storage.set('active_sessions', []); - const mixins = windowStub.ExamSystemAppMixins; if (!mixins || !mixins.examSession || !mixins.suitePractice) { throw new Error('未能加载所需的 mixin'); @@ -226,6 +212,10 @@ async function main() { const windowsMap = new Map(); const openCalls = []; let openAttempt = 0; + app._postExamMessage = (examId, targetWindow, type, data = {}) => { + targetWindow.postMessage({ type, data: { ...data, examId } }, 'http://localhost'); + return true; + }; app.openExam = async function openExamStub(examId, options = {}) { openAttempt += 1; @@ -346,7 +336,7 @@ async function main() { assert.strictEqual(handledP3, true, 'P3 完成后应顺利收尾'); assert.strictEqual(app.currentSuiteSession, null, '套题会话应在完成后被清理'); - const savedPracticeRecords = await windowStub.PracticeRecordAPI.list(); + const savedPracticeRecords = await windowStub.AppData.practice.list(); assert.strictEqual(savedPracticeRecords.length, 1, '应只生成一条套题练习记录'); assert.strictEqual(savedPracticeRecords[0].suiteEntries.length, 3, '套题记录应包含三篇文章'); @@ -378,7 +368,7 @@ async function main() { return win; }; - sessionStorageStub.clear(); + windowStub.AppData.recovery.windowSession.discard('simulation'); await appSim.startSuitePractice({ flowMode: 'simulation' }); const simSession = appSim.currentSuiteSession; assert(simSession, '模拟会话应被创建'); @@ -393,10 +383,9 @@ async function main() { assert.strictEqual(simSession.currentIndex, 1, '应前进到第二篇'); assert(simSession.draftsByExam[simP1.examId], 'P1 draft 应被保存'); - // 验证 sessionStorage 镜像 - const stored = sessionStorageStub.get('ielts_sim_session'); - assert(stored, 'sessionStorage 应包含会话镜像'); - const snapshot = JSON.parse(stored); + // 验证窗口级 recovery 领域镜像 + const snapshot = windowStub.AppData.recovery.windowSession.get('simulation'); + assert(snapshot, 'recovery.windowSession 应包含会话镜像'); assert.strictEqual(snapshot.id, simSession.id, '镜像 id 应匹配'); assert.strictEqual(snapshot.currentIndex, 1, '镜像 currentIndex 应为 1'); @@ -410,7 +399,7 @@ async function main() { const simNavOob = await appSim._handleSimulationNavigate(simP1.examId, { direction: 'prev' }, simSession.windowRef); assert.strictEqual(simNavOob, false, 'P1 向前导航应失败'); - process.stdout.write(JSON.stringify({ status: 'pass', detail: '模拟模式按顺序串联三篇题目并生成单条记录,导航与 sessionStorage 镜像正常' })); + process.stdout.write(JSON.stringify({ status: 'pass', detail: '模拟模式按顺序串联三篇题目并生成单条记录,导航与 recovery.windowSession 镜像正常' })); } main().catch(error => { diff --git a/developer/tests/js/suiteModeRegression.test.js b/developer/tests/js/suiteModeRegression.test.js index ae532678..79b6a177 100644 --- a/developer/tests/js/suiteModeRegression.test.js +++ b/developer/tests/js/suiteModeRegression.test.js @@ -4,6 +4,7 @@ import fs from 'fs'; import vm from 'vm'; import assert from 'assert'; import { fileURLToPath } from 'url'; +import { webcrypto } from 'node:crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,7 +20,7 @@ function createStubWindow(name) { return { name, closed: false, - location: { href: 'about:blank' }, + location: { href: 'http://localhost/exam.html' }, _messages: [], postMessage(payload) { this._messages.push(payload); @@ -29,24 +30,10 @@ function createStubWindow(name) { } function createSandbox() { - const storageStub = { - _data: new Map(), - async get(key, fallback = null) { - return this._data.has(key) ? this._data.get(key) : fallback; - }, - async set(key, value) { - this._data.set(key, value); - return true; - } - }; - - const sessionStorageStub = new Map(); - const sessionStorageObj = { - getItem(key) { return sessionStorageStub.get(key) || null; }, - setItem(key, value) { sessionStorageStub.set(key, String(value)); }, - removeItem(key) { sessionStorageStub.delete(key); }, - clear() { sessionStorageStub.clear(); } - }; + const cloneValue = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + const windowSessionStore = new Map(); + let activeSessions = []; + let practiceRecords = []; const documentStub = { addEventListener() {}, @@ -82,17 +69,74 @@ function createSandbox() { track(listenerStats.removed, type); }, showMessage() {}, + AppData: { + ready: Promise.resolve(), + recovery: { + async listActiveSessions() { + return cloneValue(activeSessions); + }, + windowSession: { + save(kind, value) { + windowSessionStore.set(String(kind), cloneValue(value)); + return true; + }, + get(kind) { + return cloneValue(windowSessionStore.get(String(kind)) || null); + }, + discard(kind) { + windowSessionStore.delete(String(kind)); + return true; + } + } + }, + practice: { + async list() { + return cloneValue(practiceRecords); + }, + async get(recordId) { + const target = String(recordId || ''); + const record = practiceRecords.find((item) => item && ( + String(item.id || '') === target || String(item.sessionId || '') === target + )); + return cloneValue(record || null); + }, + async getStats() { + return {}; + }, + async completeAttempt(command = {}) { + const record = cloneValue(command.record || {}); + practiceRecords.push(record); + return { committed: true, record }; + }, + async finalizeSuite(command = {}) { + const record = cloneValue(command.record || {}); + practiceRecords = [record]; + return { committed: true, record }; + } + }, + preferences: { + async patchSuite() { + return { committed: true }; + } + } + }, + async resolveExamForPracticeRecord(record) { + const examId = String(record && record.examId || ''); + return examId ? { id: examId, title: record.title || examId, type: 'reading', path: 'Reading/' + examId + '/' } : null; + }, + async resolveActiveLibraryIndex() { + return ['reading-p1', 'reading-p2', 'reading-p3'].map((id) => ({ + id, + title: id, + type: 'reading', + path: 'Reading/' + id + '/' + })); + }, CustomEvent: function CustomEvent(type, init = {}) { return { type, detail: init.detail || null }; }, location: { origin: 'http://localhost', href: 'http://localhost/' }, - localStorage: { - _data: new Map(), - getItem(key) { return this._data.has(key) ? this._data.get(key) : null; }, - setItem(key, value) { this._data.set(key, String(value)); }, - removeItem(key) { this._data.delete(key); } - }, - sessionStorage: sessionStorageObj, + crypto: webcrypto, practiceConfig: { suite: {} }, __listenerCount(type) { if (!listenerRegistry.has(type)) return 0; @@ -104,7 +148,6 @@ function createSandbox() { const sandbox = { window: windowStub, document: documentStub, - storage: storageStub, console, setTimeout, clearTimeout, @@ -113,12 +156,11 @@ function createSandbox() { Math, CustomEvent: windowStub.CustomEvent, URL, - URLSearchParams + URLSearchParams, + Uint8Array }; - windowStub.storage = storageStub; sandbox.globalThis = sandbox.window; - sandbox.window.storage = storageStub; - return { sandbox, windowStub, sessionStorageStub }; + return { sandbox, windowStub, windowSessionStore }; } function createApp(windowStub) { @@ -165,7 +207,7 @@ function makeSession(sessionId = 'suite_test_1') { } async function run() { - const { sandbox, windowStub, sessionStorageStub } = createSandbox(); + const { sandbox, windowStub, windowSessionStore } = createSandbox(); const context = vm.createContext(sandbox); loadScript('js/app/examSessionMixin.js', context); loadScript('js/app/suitePracticeMixin.js', context); @@ -250,7 +292,6 @@ async function run() { // Case 1.1: 手动回看模式下提交后不应自动跳篇 { - windowStub.localStorage.setItem('suite_auto_advance_after_submit', 'false'); const app = createApp(windowStub); const session = makeSession('suite_manual'); session.flowMode = 'classic'; @@ -281,7 +322,6 @@ async function run() { assert.strictEqual(reviewStateCount, 1, '手动模式应下发回看上下文'); assert.strictEqual(session.currentIndex, 0, '手动模式应停留在当前篇'); assert.strictEqual(session.pendingAdvance.completedExamId, 'reading-p1', '应记录待切题状态'); - windowStub.localStorage.removeItem('suite_auto_advance_after_submit'); } // Case 2: SIMULATION_NAVIGATE 前后切换并保存 draft @@ -342,10 +382,10 @@ async function run() { assert.strictEqual(session.currentIndex, 1, '应回到第二篇'); assert.deepStrictEqual(session.draftsByExam['reading-p1'].answers, { q1: 'A' }, 'P1 draft 应被保存'); assert.strictEqual(session.results.length, 2, '应记录两个篇章快照结果'); - const mirroredSession = JSON.parse(sessionStorageStub.get('ielts_sim_session')); + const mirroredSession = windowSessionStore.get('simulation'); const mirroredP2Result = mirroredSession.results.find(entry => entry.examId === 'reading-p2'); - assert.strictEqual(Object.prototype.hasOwnProperty.call(mirroredP2Result, 'highlights'), false, 'sessionStorage results 不应重复写高亮'); - assert.deepStrictEqual(mirroredSession.draftsByExam['reading-p2'].highlights, p2Highlights, 'sessionStorage 应只在 draft 中保存 P2 高亮'); + assert.strictEqual(Object.prototype.hasOwnProperty.call(mirroredP2Result, 'highlights'), false, 'window session results 不应重复写高亮'); + assert.deepStrictEqual(mirroredSession.draftsByExam['reading-p2'].highlights, p2Highlights, 'window session 应只在 draft 中保存 P2 高亮'); const p2Replay = app._buildSuiteReplayEntry(session, 'reading-p2'); assert.deepStrictEqual(p2Replay.highlights, p2Highlights, '套题中途回看必须能恢复 P2 高亮'); @@ -477,6 +517,8 @@ async function run() { }); app.openExam = async (examId, options = {}) => { assert.strictEqual(examId, 'reading-p2', '下一题应打开 P2'); + assert.strictEqual(options.examDefinition.id, 'reading-p2', '跨题回放必须传入按记录来源解析的题目定义'); + assert.strictEqual(options.requireRecordProvenance, true, '跨题回放不得回落到当前活动题库'); app.examWindows.set(examId, { window: secondWindow, reviewMode: Boolean(options.reviewMode), @@ -566,8 +608,7 @@ async function run() { scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } } }, session.windowRef); - const secondNavigate = await app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef); - assert.strictEqual(secondNavigate, false, '并发切题应被锁拒绝,避免重复导航'); + const secondNavigate = app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef); assert.strictEqual(openCallCount, 1, '并发切题期间只允许一次窗口切换'); if (typeof resolveOpen === 'function') { @@ -575,12 +616,52 @@ async function run() { } const firstNavigateOk = await firstNavigate; assert.strictEqual(firstNavigateOk, true, '首个切题请求应成功'); + assert.strictEqual(await secondNavigate, false, '重复的旧篇请求应在串行等待后按 stale 消息忽略'); session.activeExamId = 'reading-p2'; const staleNavigate = await app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef); assert.strictEqual(staleNavigate, false, '非活动篇章消息必须忽略'); } + // Case 2.2.1: 新篇提交若撞上上一跳的 ready 等待,必须排队而不能丢失 + { + const app = createApp(windowStub); + const session = makeSession('suite_nav_queue_next'); + session.currentIndex = 0; + session.activeExamId = 'reading-p1'; + app.currentSuiteSession = session; + + let releaseFirstOpen; + const opened = []; + app.openExam = async (examId) => { + opened.push(examId); + if (opened.length === 1) { + await new Promise((resolve) => { releaseFirstOpen = resolve; }); + } + return createStubWindow('suite-window'); + }; + + const firstNavigate = app._handleSimulationNavigate( + 'reading-p1', + { direction: 'next' }, + session.windowRef + ); + await Promise.resolve(); + const queuedNavigate = app._handleSimulationNavigate( + 'reading-p2', + { direction: 'next' }, + session.windowRef + ); + assert.deepStrictEqual(opened, ['reading-p2'], '锁内只应启动第一跳'); + + releaseFirstOpen(); + assert.strictEqual(await firstNavigate, true, '第一跳应成功'); + assert.strictEqual(await queuedNavigate, true, '下一篇提交应在第一跳完成后继续处理'); + assert.deepStrictEqual(opened, ['reading-p2', 'reading-p3'], '排队提交应继续切到 P3'); + assert.strictEqual(session.currentIndex, 2, '串行导航后索引应到达 P3'); + assert.strictEqual(session.activeExamId, 'reading-p3', '串行导航后活动篇章应到达 P3'); + } + // Case 2.3: 重复绑定同一 exam 消息通道时必须替换旧监听器 { const app = createApp(windowStub); @@ -626,6 +707,7 @@ async function run() { const info = app.ensureExamWindowSession('reading-p2', examWindow); info.expectedSessionId = 'expected_session'; + app._refreshExamWindowToken('reading-p2', info); info.suiteSessionId = session.id; app.examWindows.set('reading-p2', info); @@ -649,6 +731,7 @@ async function run() { examId: 'reading-p2', suiteSessionId: session.id, sessionId: 'stale_session', + windowSessionToken: info.windowSessionToken, direction: 'prev', source: 'practice_page' }, @@ -680,6 +763,7 @@ async function run() { const info = app.ensureExamWindowSession('reading-p1', examWindow); info.expectedSessionId = 'expected_inline_session'; + app._refreshExamWindowToken('reading-p1', info); info.suiteSessionId = session.id; app.examWindows.set('reading-p1', info); @@ -693,6 +777,7 @@ async function run() { examId: 'reading-p2', suiteSessionId: session.id, sessionId: 'stale_inline_session', + windowSessionToken: info.windowSessionToken, draft: { answers: { q1: 'P2 answer' }, highlights: [{ scope: 'left', text: 'P2 highlight' }], @@ -712,7 +797,7 @@ async function run() { assert.strictEqual(session.draftsByExam['reading-p2'].noteText, 'P2 note', 'P2 noteText 应保存'); } - // Case 2.4.2: inline simulation 草稿同步必须按篇拆分 elapsed,并镜像回 sessionStorage + // Case 2.4.2: inline simulation 草稿同步必须按篇拆分 elapsed,并镜像回窗口会话域 { const app = createApp(windowStub); const session = makeSession('suite_inline_elapsed_route'); @@ -732,6 +817,7 @@ async function run() { const info = app.ensureExamWindowSession('reading-p1', examWindow); info.expectedSessionId = 'expected_inline_elapsed_session'; + app._refreshExamWindowToken('reading-p1', info); info.suiteSessionId = session.id; app.examWindows.set('reading-p1', info); @@ -745,6 +831,7 @@ async function run() { examId: 'reading-p2', suiteSessionId: session.id, sessionId: 'stale_inline_elapsed_session', + windowSessionToken: info.windowSessionToken, draft: { answers: { q1: 'P2 answer' }, highlights: [], @@ -761,8 +848,8 @@ async function run() { }); assert.strictEqual(session.elapsedByExam['reading-p2'], 60, 'P2 elapsed 必须按整套累计时间拆分为单篇时长'); - const mirrored = JSON.parse(sessionStorageStub.get('ielts_sim_session')); - assert.strictEqual(mirrored.elapsedByExam['reading-p2'], 60, 'sessionStorage 镜像也必须保存拆分后的 P2 elapsed'); + const mirrored = windowSessionStore.get('simulation'); + assert.strictEqual(mirrored.elapsedByExam['reading-p2'], 60, '窗口会话镜像也必须保存拆分后的 P2 elapsed'); } // Case 2.5: activeExamId 漂移但 currentIndex 正确时,导航应自愈继续 @@ -892,6 +979,316 @@ async function run() { assert.deepStrictEqual(plain(session.draftsByExam['reading-p2'].highlights), [{ scope: 'groups', text: 'P2 highlight' }], 'P2 高亮应隔离保存'); } + // Case 3.0.2: inline simulation 提交必须在落库后 ACK,并可按同一 submissionId 重放 + { + const app = createApp(windowStub); + const session = makeSession('suite_inline_submit_ack'); + const sourceWindow = session.windowRef; + const examId = 'reading-p1'; + const sessionId = 'session-inline-submit-ack'; + const submissionId = 'submission-inline-submit-ack'; + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: 'token-inline-submit-ack', + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + suiteSessionId: session.id + }]]); + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId: session.id, + suiteSubmission: true, + duration: 3600, + suiteEntries: session.sequence.map((entry, index) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.exam.category, + duration: 1200, + answers: { q1: String.fromCharCode(65 + index) }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + })) + }; + + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true); + let ack = sourceWindow._messages.filter(message => message && message.type === 'PRACTICE_SUBMIT_ACK').at(-1); + assert(ack, 'inline simulation persistence must ACK the child'); + assert.deepStrictEqual(plain({ + submissionId: ack.data.submissionId, + sessionId: ack.data.sessionId, + examId: ack.data.examId, + suiteSessionId: ack.data.suiteSessionId + }), { submissionId, sessionId, examId, suiteSessionId: session.id }); + assert.strictEqual((await windowStub.AppData.practice.list()).length, 1, 'first submit must persist one suite record'); + + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true); + ack = sourceWindow._messages.filter(message => message && message.type === 'PRACTICE_SUBMIT_ACK').at(-1); + assert(ack, 'retry must replay the persisted ACK'); + assert.strictEqual((await windowStub.AppData.practice.list()).length, 1, 'retry must not persist a second suite record'); + clearTimeout(session.submitReceiptTeardownTimer); + session.submitReceiptTeardownTimer = null; + } + + // Case 3.0.3: multi-suite 保存失败必须 NACK,同键重试成功后才 ACK + { + const app = createApp(windowStub); + const examId = 'listening-multi-suite'; + const sessionId = 'session-multi-submit'; + const submissionId = 'submission-multi-submit'; + const suiteSessionId = 'suite-session-multi-submit'; + const sourceWindow = createStubWindow('multi-suite-submit'); + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: 'token-multi-submit', + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + let saveAttempts = 0; + app._saveSuitePracticeRecord = async () => { + saveAttempts += 1; + if (saveAttempts === 1) throw new Error('expected multi-suite save failure'); + }; + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId, + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), false); + let outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1); + assert.strictEqual(outcome.type, 'PRACTICE_SUBMIT_FAILED'); + assert.deepStrictEqual(plain({ + submissionId: outcome.data.submissionId, + sessionId: outcome.data.sessionId, + examId: outcome.data.examId, + suiteSessionId: outcome.data.suiteSessionId + }), { submissionId, sessionId, examId, suiteSessionId }); + + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true); + outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1); + assert.strictEqual(outcome.type, 'PRACTICE_SUBMIT_ACK'); + assert.strictEqual(saveAttempts, 2, 'retry must re-attempt the failed aggregate save exactly once'); + } + + // Case 3.0.4: canonical receipt 未确认 committed 时必须 NACK + { + const app = createApp(windowStub); + const examId = 'listening-multi-uncommitted-receipt'; + const sessionId = 'session-multi-uncommitted-receipt'; + const submissionId = 'submission-multi-uncommitted-receipt'; + const sourceWindow = createStubWindow('multi-uncommitted-receipt'); + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: 'token-multi-uncommitted-receipt', + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + const originalFinalizeSuite = windowStub.AppData.practice.finalizeSuite; + windowStub.AppData.practice.finalizeSuite = async () => ({ committed: false }); + try { + const committed = await app.handlePracticeComplete(examId, { + examId, + sessionId, + submissionId, + suiteSessionId: 'suite-multi-uncommitted-receipt', + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, sourceWindow); + assert.strictEqual(committed, false, 'uncommitted canonical receipt must not be treated as success'); + } finally { + windowStub.AppData.practice.finalizeSuite = originalFinalizeSuite; + } + const outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1); + assert(outcome && outcome.type === 'PRACTICE_SUBMIT_FAILED', 'uncommitted canonical receipt must NACK'); + } + + // Case 3.0.5: reading suite 聚合提交后的 UI/清理故障不得触发单篇 fallback 或假 NACK + for (const failingStep of ['sync', 'overview', 'message', 'teardown-schedule']) { + const app = createApp(windowStub); + const session = makeSession(`suite_post_commit_${failingStep}`); + const sourceWindow = session.windowRef; + const examId = 'reading-p1'; + const sessionId = `session-post-commit-${failingStep}`; + const submissionId = `submission-post-commit-${failingStep}`; + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: `token-post-commit-${failingStep}`, + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + suiteSessionId: session.id + }]]); + + const aggregateRecords = []; + let partialFallbacks = 0; + let standaloneFallbacks = 0; + app._saveSuitePracticeRecord = async (record) => { + aggregateRecords.push(record); + }; + app._savePartialSuiteAsIndividual = async () => { + partialFallbacks += 1; + }; + app.saveRealPracticeData = async () => { + standaloneFallbacks += 1; + return { id: `unexpected-standalone-${failingStep}` }; + }; + if (failingStep === 'sync') { + app._updatePracticeRecordsState = async () => { throw new Error('expected sync failure'); }; + } else if (failingStep === 'overview') { + app.refreshOverviewData = () => { throw new Error('expected overview failure'); }; + } else if (failingStep === 'teardown-schedule') { + app._scheduleSuiteSubmitTeardown = () => { throw new Error('expected teardown scheduling failure'); }; + } + const originalShowMessage = windowStub.showMessage; + if (failingStep === 'message') { + windowStub.showMessage = () => { throw new Error('expected completion message failure'); }; + } + + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId: session.id, + suiteSubmission: true, + duration: 3600, + suiteEntries: session.sequence.map((entry, index) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.exam.category, + duration: 1200, + answers: { q1: String.fromCharCode(65 + index) }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + })) + }; + + try { + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: committed suite must return success`); + app.examWindows.get(examId).practiceSubmitReceipts = {}; + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: completed-session retry must return success without receipt cache`); + } finally { + windowStub.showMessage = originalShowMessage; + if (session.submitReceiptTeardownTimer) { + clearTimeout(session.submitReceiptTeardownTimer); + session.submitReceiptTeardownTimer = null; + } + } + const outcomes = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)); + assert(outcomes.length >= 2 && outcomes.every(message => message.type === 'PRACTICE_SUBMIT_ACK'), `${failingStep}: commit and completed-session retry must only ACK`); + assert.strictEqual(aggregateRecords.length, 1, `${failingStep}: aggregate record must be written exactly once`); + assert.strictEqual(partialFallbacks, 0, `${failingStep}: individual suite fallback must not run after commit`); + assert.strictEqual(standaloneFallbacks, 0, `${failingStep}: outer standalone fallback must not run after commit`); + assert.strictEqual(session.status, 'completed', `${failingStep}: committed session must stay completed`); + } + + // Case 3.0.6: multi-suite 聚合提交后的各后置步骤故障仍须 ACK 且保持单次聚合写入 + for (const failingStep of ['spelling', 'sync', 'overview', 'session-cleanup', 'message']) { + const app = createApp(windowStub); + const examId = `listening-multi-post-commit-${failingStep}`; + const sessionId = `session-multi-post-commit-${failingStep}`; + const submissionId = `submission-multi-post-commit-${failingStep}`; + const suiteSessionId = `suite-multi-post-commit-${failingStep}`; + const sourceWindow = createStubWindow(`multi-post-commit-${failingStep}`); + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: `token-multi-post-commit-${failingStep}`, + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + + const aggregateRecords = []; + let standaloneFallbacks = 0; + app._saveSuitePracticeRecord = async (record) => { + aggregateRecords.push(record); + }; + app.saveRealPracticeData = async () => { + standaloneFallbacks += 1; + return { id: `unexpected-multi-standalone-${failingStep}` }; + }; + if (failingStep === 'sync') { + app._updatePracticeRecordsState = async () => { throw new Error('expected multi sync failure'); }; + } else if (failingStep === 'overview') { + app.refreshOverviewData = () => { throw new Error('expected multi overview failure'); }; + } else if (failingStep === 'session-cleanup') { + app.multiSuiteSessionsMap = new class extends Map { + delete() { throw new Error('expected multi session cleanup failure'); } + }(); + } + const originalShowMessage = windowStub.showMessage; + const originalCollector = windowStub.spellingErrorCollector; + windowStub.spellingErrorCollector = { + async saveErrors() { + if (failingStep === 'spelling') throw new Error('expected spelling sync failure'); + } + }; + if (failingStep === 'message') { + windowStub.showMessage = () => { throw new Error('expected multi completion message failure'); }; + } + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId, + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + spellingErrors: [{ word: 'practice', answer: 'practise' }] + }; + + try { + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: committed multi-suite must return success`); + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: multi-suite receipt replay must return success`); + } finally { + windowStub.showMessage = originalShowMessage; + windowStub.spellingErrorCollector = originalCollector; + } + const outcomes = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)); + assert(outcomes.length >= 2 && outcomes.every(message => message.type === 'PRACTICE_SUBMIT_ACK'), `${failingStep}: multi-suite commit and replay must only ACK`); + assert.strictEqual(aggregateRecords.length, 1, `${failingStep}: multi-suite aggregate must be written exactly once`); + assert.strictEqual(standaloneFallbacks, 0, `${failingStep}: multi-suite must not enter standalone fallback after commit`); + } + // Case 3.1: 如果最后一篇已有导航快照,最终提交仍应覆盖并 finalize { const app = createApp(windowStub); @@ -959,16 +1356,16 @@ async function run() { assert.deepStrictEqual(savedExamIds.sort(), ['reading-p1', 'reading-p2'], '中断后应保存所有已作答篇章'); } - // Case 5: sessionStorage 镜像在 teardown 后应被清理 + // Case 5: AppData window-session mirror must be cleared after teardown { const app = createApp(windowStub); const session = makeSession('suite_storage_cleanup'); app.currentSuiteSession = session; app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); app._mirrorSessionToStorage(session); - assert(sessionStorageStub.has('ielts_sim_session'), '镜像应存在'); + assert(windowSessionStore.has('simulation'), '镜像应存在'); app._clearSessionStorage(); - assert(!sessionStorageStub.has('ielts_sim_session'), '清理后镜像应删除'); + assert(!windowSessionStore.has('simulation'), '清理后镜像应删除'); } // Case 6: _sendSimulationContext 应发送正确的上下文 @@ -1018,6 +1415,28 @@ async function run() { assert.deepStrictEqual(payload.suiteSequence.map(item => item.category), ['P1', 'P2', 'P3'], 'INIT suiteSequence 应带 category'); } + // Case 6.2: 占位页 URL 必须显式传播窄范围 suite 测试标志 + { + const app = createApp(windowStub); + const placeholderUrl = app._buildExamPlaceholderUrl( + { + id: 'reading-p1', + title: 'Passage 1 & 特殊字符', + category: 'P1' + }, + { + suiteSessionId: 'suite placeholder session', + sequenceIndex: 0 + } + ); + const parsed = new URL(placeholderUrl); + assert.strictEqual(parsed.pathname.endsWith('/templates/exam-placeholder.html'), true, '应使用套题占位页'); + assert.strictEqual(parsed.searchParams.get('suite_test'), '1', '占位页必须收到 suite_test=1'); + assert.strictEqual(parsed.searchParams.get('suiteSessionId'), 'suite placeholder session', '套题会话 ID 应 round-trip'); + assert.strictEqual(parsed.searchParams.get('title'), 'Passage 1 & 特殊字符', '标题特殊字符应由 URLSearchParams 安全编码'); + assert.strictEqual(parsed.searchParams.get('index'), '0', '首篇 index=0 不应被省略'); + } + // Case 8: handleSessionReady 应触发首篇模拟上下文下发 { const app = createApp(windowStub); @@ -1211,6 +1630,7 @@ async function run() { const info = app.ensureExamWindowSession(examId, examWindow); info.expectedSessionId = expectedSessionId; + app._refreshExamWindowToken(examId, info); app.examWindows.set(examId, info); const handler = app.messageHandlers.get(examId); @@ -1226,6 +1646,8 @@ async function run() { source: 'listening_record_bridge', examId: 'listening-unknown', sessionId: 'listening-unknown_123', + submissionId: 'listening-submit-teacher-pack', + windowSessionToken: info.windowSessionToken, practiceType: 'listening', pageType: 'listening', answers: { q1: 'acommodation' }, @@ -1255,7 +1677,13 @@ async function run() { app.components.practiceRecorder = { handleSessionCompleted: async (payload) => { savedCompletions.push(payload); - return { id: 'record-custom-listening', examId }; + const record = { + id: 'record-custom-listening', + examId, + sessionId: `${examId}_session`, + endTime: '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; } }; app.updateExamStatus = (handledExamId, nextStatus) => { @@ -1313,7 +1741,15 @@ async function run() { const savedErrors = []; app.components.practiceRecorder = { - handleSessionCompleted: async () => ({ id: 'record-normalized-errors', examId }) + handleSessionCompleted: async () => { + const record = { + id: 'record-normalized-errors', + examId, + sessionId: `${examId}_session`, + endTime: '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; + } }; app.updateExamStatus = () => {}; app.showRealCompletionNotification = () => {}; @@ -1375,6 +1811,7 @@ async function run() { const info = app.ensureExamWindowSession(examId, examWindow); info.expectedSessionId = expectedSessionId; + app._refreshExamWindowToken(examId, info); app.examWindows.set(examId, info); examWindow._messages.length = 0; @@ -1417,6 +1854,7 @@ async function run() { source: 'listening_record_bridge', examId, sessionId: expectedSessionId, + windowSessionToken: info.windowSessionToken, pageType: 'listening', type: 'listening', initialized: true @@ -1431,6 +1869,44 @@ async function run() { } } + // Case 11.1: 占位页无 token 的 bootstrap ready 也只能触发 INIT,不能结束握手 + { + const app = createApp(windowStub); + const examWindow = createStubWindow('suite-placeholder-handshake-window'); + const examId = 'suite-placeholder-handshake'; + app.setupExamWindowCommunication(examWindow, examId, { id: examId, type: 'reading' }); + const info = app.ensureExamWindowSession(examId, examWindow); + info.expectedOrigin = 'null'; + info.allowOpaqueOrigin = true; + examWindow._messages.length = 0; + + const timer = setInterval(() => {}, 10000); + app._handshakeTimers = new Map([[examId, timer]]); + try { + await app.messageHandlers.get(examId)({ + source: examWindow, + origin: 'file://', + data: { + type: 'SESSION_READY', + source: 'suite_placeholder', + data: { + source: 'suite_placeholder', + examId, + sessionId: null, + windowSessionToken: null, + pageType: 'suite-placeholder' + } + } + }); + + assert.strictEqual(app._handshakeTimers.has(examId), true, '占位页 bootstrap ready 不得停止 INIT 重试'); + assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, undefined, '无 token ready 不得标记 collector ready'); + assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), '无 token ready 后应立即补发 INIT_SESSION'); + } finally { + clearInterval(timer); + } + } + // Case 12: 听力完成早于 initialized ready 时,也必须先补建 recorder session 再落库 { const app = createApp(windowStub); @@ -1466,7 +1942,13 @@ async function run() { assert(this.activeSessions.has(payload.examId), '真实 recorder 没有 active session 会拒绝落库'); const session = this.activeSessions.get(payload.examId); assert.strictEqual(session.sessionId, payload.sessionId, '完成 payload 必须使用父页面 expectedSessionId'); - return { id: `record_${payload.sessionId}`, examId: payload.examId, sessionId: payload.sessionId }; + const record = { + id: `record_${payload.sessionId}`, + examId: payload.examId, + sessionId: payload.sessionId, + endTime: payload.endTime || '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; } }; app.updateExamStatus = (handledExamId, nextStatus) => { @@ -1484,6 +1966,7 @@ async function run() { const info = app.ensureExamWindowSession(examId, examWindow); info.expectedSessionId = expectedSessionId; + app._refreshExamWindowToken(examId, info); app.examWindows.set(examId, info); const handler = app.messageHandlers.get(examId); @@ -1499,6 +1982,8 @@ async function run() { source: 'listening_record_bridge', examId: 'listening-unknown', sessionId: 'listening-unknown_early', + submissionId: 'listening-submit-complete-first', + windowSessionToken: info.windowSessionToken, practiceType: 'listening', pageType: 'listening', title: 'Complete First Listening', @@ -1542,7 +2027,13 @@ async function run() { async handleSessionCompleted(payload) { completions.push(payload); this.activeSessions.delete(payload.examId); - return { id: `record_${payload.sessionId}`, examId: payload.examId, sessionId: payload.sessionId }; + const record = { + id: `record_${payload.sessionId}`, + examId: payload.examId, + sessionId: payload.sessionId, + endTime: payload.endTime || '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; }, handleSessionStarted(payload) { recorderStarts.push(payload); @@ -1589,6 +2080,7 @@ async function run() { const info = app.ensureExamWindowSession(examId, examWindow); info.expectedSessionId = firstSessionId; + app._refreshExamWindowToken(examId, info); app.examWindows.set(examId, info); const handler = app.messageHandlers.get(examId); @@ -1603,6 +2095,8 @@ async function run() { data: { examId, sessionId: firstSessionId, + submissionId: 'reading-submit-first-session', + windowSessionToken: info.windowSessionToken, answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } @@ -1624,6 +2118,7 @@ async function run() { assert.strictEqual(completions.length, 1, '统一阅读完成应正常进入 recorder'); examWindow._messages.length = 0; + recorderStarts.length = 0; await handler({ source: examWindow, origin: 'http://localhost', @@ -1633,6 +2128,7 @@ async function run() { data: { examId, sessionId: firstSessionId, + windowSessionToken: info.windowSessionToken, reason: 'retake-after-submit', fromPracticeMode: 'single', targetPracticeMode: 'single', diff --git a/developer/tests/js/suitePracticeStorageFallback.test.js b/developer/tests/js/suitePracticeStorageFallback.test.js index 69c607df..6c4b2c94 100644 --- a/developer/tests/js/suitePracticeStorageFallback.test.js +++ b/developer/tests/js/suitePracticeStorageFallback.test.js @@ -20,44 +20,30 @@ function deepClone(value) { } async function main() { - const state = new Map(); - state.set('practice_records', [{ id: 'legacy_1', examId: 'legacy-a' }]); - const storage = { - async get(key, fallback = undefined) { - if (state.has(key)) { - return deepClone(state.get(key)); - } - return deepClone(fallback); - }, - async set(key, value) { - state.set(key, deepClone(value)); - } - }; - + const practiceListCalls = []; const sandboxWindow = { location: { href: 'http://localhost/' }, showMessage() {}, addEventListener() {}, removeEventListener() {}, document: { addEventListener() {}, removeEventListener() {} }, - // 统一入口:PracticeRecordAPI 是套题练习记录的唯一读取通道 - PracticeRecordAPI: { - async list() { + AppData: { + ready: Promise.resolve(), + practice: { + async list(options) { + practiceListCalls.push(deepClone(options)); return [{ id: 'api_1', examId: 'api-a' }]; }, - async saveRecord() { - throw new Error('saveRecord should not be called in this read test'); - }, - async recalculateStats() { + async getStats() { return { totalPractices: 1 }; } + } } }; const sandbox = { window: sandboxWindow, document: sandboxWindow.document, - storage, console, setTimeout, clearTimeout, @@ -86,22 +72,26 @@ async function main() { Object.assign(app, mixins.examSession, mixins.suitePractice); - // 统一入口验证:_loadSuitePracticeRecordsForFiltering 应通过 PracticeRecordAPI.list 读取 + // 统一入口验证:过滤和聚合只读 AppData.practice const fromFiltering = await app._loadSuitePracticeRecordsForFiltering(); assert.ok(Array.isArray(fromFiltering) && fromFiltering.length > 0, '过滤读取应返回记录'); - assert.strictEqual(fromFiltering[0].id, 'api_1', '过滤读取应通过 PracticeRecordAPI.list 获取'); + assert.strictEqual(fromFiltering[0].id, 'api_1', '过滤读取应通过 AppData.practice.list 获取'); - // _listPracticeRecordsViaAPI 也应直接走 PracticeRecordAPI.list + // 兼容命名的方法内部也必须直达领域 API const viaAPI = await app._listPracticeRecordsViaAPI(); assert.ok(Array.isArray(viaAPI) && viaAPI.length > 0, 'API 读取应返回记录'); - assert.strictEqual(viaAPI[0].id, 'api_1', 'API 读取应通过 PracticeRecordAPI.list 获取'); + assert.strictEqual(viaAPI[0].id, 'api_1', 'API 读取应通过 AppData.practice.list 获取'); + assert.deepStrictEqual(practiceListCalls, [ + { projection: 'detail' }, + { projection: 'detail' } + ], '套题去重只应读取详情层,不得加载高亮和笔记层'); + assert.strictEqual(await app._recalculatePracticeStatsFromRecords(), true); - // 无 PracticeRecordAPI 时应返回空数组,不崩溃 - delete sandboxWindow.PracticeRecordAPI; - const emptyResult = await app._listPracticeRecordsViaAPI(); - assert.ok(Array.isArray(emptyResult) && emptyResult.length === 0, '无 API 时应安全返回空数组'); + // 缺少事实层必须明确失败,不能伪装成空记录 + delete sandboxWindow.AppData; + await assert.rejects(() => app._listPracticeRecordsViaAPI(), /AppData|practice/); - process.stdout.write(JSON.stringify({ status: 'pass', detail: 'suitePractice 统一通过 PracticeRecordAPI 读取记录' })); + process.stdout.write(JSON.stringify({ status: 'pass', detail: 'suitePractice only reads AppData.practice and does not fake empty data when unavailable' })); } main().catch((error) => { diff --git a/developer/tests/js/suitePreference.test.js b/developer/tests/js/suitePreference.test.js new file mode 100644 index 00000000..b5cc7f5b --- /dev/null +++ b/developer/tests/js/suitePreference.test.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const source = fs.readFileSync(path.join(repoRoot, 'js/utils/suitePreference.js'), 'utf8'); + +// Mirrors practiceTimerPreferences.test.js: each call spins up a fresh vm +// context so the IIFE-captured hydrationPromise cache is reset between +// cases. tenanceAppData.preferences.getSuite is stubbed per scenario so the +// contract "reload must surface stored suite preference on first read" can +// be asserted without racing eager hydration. +function loadSuitePreference(stored) { + const persisted = JSON.parse(JSON.stringify(stored == null ? {} : stored)); + const patchCalls = []; + const window = { + AppData: { + ready: Promise.resolve(true), + preferences: { + async getSuite() { + return JSON.parse(JSON.stringify(persisted)); + }, + async patchSuite(patch) { + patchCalls.push(JSON.parse(JSON.stringify(patch))); + } + } + } + }; + const context = { + window, + globalThis: window, + Object, + Number, + Math, + JSON, + String, + Boolean, + Promise, + console: { log() {}, warn() {}, error() {} } + }; + vm.runInNewContext(source, context, { filename: 'suitePreference.js' }); + return { + utils: window.SuitePreferenceUtils, + patchCalls, + persisted + }; +} + +function loadSuitePreferenceBeforeAppData(stored) { + const persisted = JSON.parse(JSON.stringify(stored == null ? {} : stored)); + const patchCalls = []; + const window = {}; + const context = { + window, + globalThis: window, + Object, + Number, + Math, + JSON, + String, + Boolean, + Promise, + console: { log() {}, warn() {}, error() {} } + }; + vm.runInNewContext(source, context, { filename: 'suitePreference.js' }); + window.AppData = { + ready: Promise.resolve(true), + preferences: { + async getSuite() { return JSON.parse(JSON.stringify(persisted)); }, + async patchSuite(patch) { patchCalls.push(JSON.parse(JSON.stringify(patch))); } + } + }; + return { utils: window.SuitePreferenceUtils, patchCalls }; +} + +const plain = (value) => JSON.parse(JSON.stringify(value)); + +// Behaviour contract for Fix 6: after a reload the first call to +// resolveSuitePreference() must surface the persisted suite preference +// (e.g. flowMode='simulation'), not the classic default, because hydration +// is awaited before any suiteConfig read. +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils, patchCalls } = loadSuitePreference(stored); + + // eagerly-kicked hydration has a chance to settle before we await, but + // resolveSuitePreference awaits it anyway; the first read must reflect + // the stored value regardless of timing. + const first = await utils.resolveSuitePreference(); + assert.equal(first.flowMode, 'simulation', + 'first resolve after reload must surface the stored flowMode, not the classic default'); + assert.equal(first.frequencyScope, 'high', + 'first resolve after reload must surface the stored frequencyScope'); + assert.equal(first.autoAdvanceAfterSubmit, false, + 'first resolve after reload must surface the stored autoAdvanceAfterSubmit'); + assert.deepEqual(plain(first), { + flowMode: 'simulation', + frequencyScope: 'high', + autoAdvanceAfterSubmit: false + }); + // The promise-based read must not mutate persistence on its own. + assert.equal(patchCalls.length, 0, 'resolveSuitePreference must not patch preferences'); +} + +// Reverse assertion: once hydration has been awaited, a subsequent call +// must keep returning the stored preference (the cached hydrationPromise +// short-circuits, so the stored value must still answer). ready is the +// hydrateSuitePreference function reference, so it must be invoked, not +// awaited as a bare value. +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils } = loadSuitePreference(stored); + + await utils.ready(); + const result = await utils.resolveSuitePreference(); + assert.equal(result.flowMode, 'simulation'); + assert.equal(result.frequencyScope, 'high'); + const second = await utils.resolveSuitePreference(); + assert.equal(second.flowMode, 'simulation', + 'a second resolve after hydration must still report the stored preference'); + assert.equal(second.frequencyScope, 'high'); +} + +// Contract default when nothing is persisted: resolve must fall back to the +// canonical classic/all defaults even after hydration runs to completion. +{ + const { utils } = loadSuitePreference({}); + const result = await utils.resolveSuitePreference(); + assert.equal(result.flowMode, 'classic', + 'empty persisted suite must resolve to the classic flowMode default'); + assert.equal(result.frequencyScope, 'all', + 'empty persisted suite must resolve to the all frequencyScope default'); + assert.equal(result.autoAdvanceAfterSubmit, true, + 'classic fallback must auto-advance after submit'); +} + +// persistSuitePreference is synchronous and reads config.suite inline: after +// hydration surfaces simulation, a persist without an explicit flowMode must +// keep the stored flowMode (Fix 6 contract: persist no longer defers to +// resolveSuitePreference, but the value was already hydrated into config.suite). +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils, patchCalls } = loadSuitePreference(stored); + + await utils.ready(); + const persisted = utils.persistSuitePreference({ autoAdvanceAfterSubmit: true }); + assert.equal(persisted.flowMode, 'simulation', + 'persist must reuse the hydrated flowMode when the caller omits it'); + assert.equal(persisted.frequencyScope, 'high', + 'persist must reuse the hydrated frequencyScope when the caller omits it'); + assert.equal(persisted.autoAdvanceAfterSubmit, true, + 'persist must honour the explicit autoAdvanceAfterSubmit override'); + // persist fires patchSuite asynchronously after hydration settles. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(patchCalls.length, 1, 'persist must persist the resolved preference once'); + assert.deepEqual(patchCalls[0], { + flowMode: 'simulation', + frequencyScope: 'high', + autoAdvanceAfterSubmit: true + }); +} + +// Race regression for the suitePracticeMixin synchronous reader: even when +// resolveSuitePreference is never awaited, the eagerly-kicked hydration must +// populate config.suite before ensurePracticeConfig().suite is read. We +// emulate the mixin by awaiting utils.ready() (which is hydrateSuitePreference) +// then synchronously inspecting ensurePracticeConfig().suite. +{ + const stored = { flowMode: 'stationary', frequencyScope: 'custom', autoAdvanceAfterSubmit: true }; + const { utils } = loadSuitePreference(stored); + await utils.ready(); + const suiteConfig = utils.ensurePracticeConfig().suite; + assert.equal(suiteConfig.flowMode, 'stationary', + 'eager hydration must populate config.suite.flowMode for synchronous readers'); + assert.equal(suiteConfig.frequencyScope, 'custom', + 'eager hydration must populate config.suite.frequencyScope for synchronous readers'); + assert.equal(suiteConfig.autoAdvanceAfterSubmit, true, + 'eager hydration must populate config.suite.autoAdvanceAfterSubmit for synchronous readers'); +} + +// runtime-entry loads before core-foundation, so the eager call can legitimately +// run before AppData exists. That miss must not be cached as a permanent false. +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils } = loadSuitePreferenceBeforeAppData(stored); + const result = await utils.resolveSuitePreference(); + assert.equal(result.flowMode, 'simulation', + 'late AppData installation must retry suite hydration after the eager early miss'); + assert.equal(result.frequencyScope, 'high'); + assert.equal(result.autoAdvanceAfterSubmit, false); +} + +process.stdout.write(JSON.stringify({ + status: 'pass', + detail: ' suitePreference resolves the hydrated suite preference on first read instead of the classic default' +})); diff --git a/developer/tests/js/unifiedReadingCoreRegression.test.js b/developer/tests/js/unifiedReadingCoreRegression.test.js index 25a1bb82..c94da775 100644 --- a/developer/tests/js/unifiedReadingCoreRegression.test.js +++ b/developer/tests/js/unifiedReadingCoreRegression.test.js @@ -15,22 +15,6 @@ function loadScript(relativePath, context) { vm.runInContext(code, context, { filename: relativePath }); } -function createSessionStorageStub() { - const store = new Map(); - return { - store, - getItem(key) { - return store.has(key) ? store.get(key) : null; - }, - setItem(key, value) { - store.set(key, String(value)); - }, - removeItem(key) { - store.delete(key); - } - }; -} - function createClassList() { return { add() {}, @@ -51,7 +35,6 @@ function createContext() { HTMLSelectElement.prototype = Object.create(HTMLElement.prototype); HTMLSelectElement.prototype.constructor = HTMLSelectElement; - const sessionStorage = createSessionStorageStub(); const timer = { textContent: '', style: {}, @@ -125,7 +108,6 @@ function createContext() { }, history: { replaceState() {} }, document, - sessionStorage, opener: null, parent: null, addEventListener() {}, @@ -195,7 +177,6 @@ function createContext() { HTMLSelectElement, CustomEvent: window.CustomEvent, CSS: window.CSS, - sessionStorage, location: window.location }; sandbox.globalThis = window; @@ -254,19 +235,76 @@ async function testSubmitPostsBeforeExplanationRenderFinishes() { releaseExplanation = resolve; })); - const submitPromise = hooks.handleSubmit(); + let submitError = null; + const submitPromise = hooks.handleSubmit().catch((error) => { + submitError = error; + }); await Promise.resolve(); + assert.ifError(submitError); assert.strictEqual(messages.length, 1, 'submit should notify host before explanation rendering completes'); assert.strictEqual(messages[0].type, 'PRACTICE_COMPLETE', 'submit should post a practice completion message'); assert.strictEqual(messages[0].data?.answers?.q1, 'A', 'posted submission should include the current answer'); releaseExplanation(); await submitPromise; + assert.ifError(submitError); hooks.setTestOverride('renderExplanations', null); assert.strictEqual(window.__UNIFIED_READING_SIMULATION_MODE__, false, 'submit regression harness should remain in non-simulation mode'); } +function testDraftBearingInitIsNotSuppressed() { + const { hooks } = loadHooks(); + const baseData = { + examId: 'reading-p1', + sessionId: 'session-init', + windowSessionToken: 'token-init', + messageIssuedAtMs: 1000 + }; + const noDraftSignature = hooks.buildInitSignature(baseData); + const draftData = { + ...baseData, + draft: { answers: { q1: 'A' }, updatedAt: 2000 } + }; + const draftSignature = hooks.buildInitSignature(draftData); + assert.notStrictEqual( + draftSignature, + noDraftSignature, + 'a later draft-bearing INIT must not be suppressed by an earlier no-draft INIT' + ); + assert.strictEqual( + hooks.buildInitSignature(draftData), + draftSignature, + 'repeated INITs carrying the same draft should still be deduplicated' + ); +} + +function testSuiteReviewAnnotationsUseDraftChannel() { + const { hooks } = loadHooks(); + const messages = []; + const hostWindow = { + postMessage(payload) { + messages.push(payload); + } + }; + hooks.setTestState({ + examId: 'reading-suite-review', + sessionId: 'session-suite-review', + suiteSessionId: 'suite-review', + simulationMode: true, + suiteReviewMode: true, + reviewMode: true, + readOnly: true, + parentWindow: hostWindow + }); + + hooks.syncReadingAnnotation('note-edit'); + + assert.strictEqual(messages.length, 1, 'suite review annotation should emit one persistence message'); + assert.strictEqual(messages[0].type, 'SIMULATION_DRAFT_SYNC', 'suite review annotation must use the suite draft channel'); + assert.strictEqual(messages[0].data?.examId, 'reading-suite-review', 'suite draft sync must retain the active exam id'); +} + function testGroupedCheckboxSplitKeysScorePartially() { const { hooks } = loadHooks(); const results = hooks.buildResultsFromAnswers({ @@ -358,6 +396,8 @@ function testSuiteTimerIgnoresEmptyLimitValues() { async function main() { await testSubmitPostsBeforeExplanationRenderFinishes(); + testDraftBearingInitIsNotSuppressed(); + testSuiteReviewAnnotationsUseDraftChannel(); testGroupedCheckboxSplitKeysScorePartially(); testGroupedCheckboxSingleKeyArrayScoresPartially(); testAcceptedAnswerArraysStaySinglePoint(); diff --git a/developer/tests/js/unifiedReadingLockRegression.test.js b/developer/tests/js/unifiedReadingLockRegression.test.js index 51853362..8d5b3117 100644 --- a/developer/tests/js/unifiedReadingLockRegression.test.js +++ b/developer/tests/js/unifiedReadingLockRegression.test.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from 'fs'; import path from 'path'; +import vm from 'vm'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -15,7 +16,101 @@ function ok(cond, label, failed) { if (!cond) failed.push(label); } -function run() { +async function testEndlessLifecycle(failed) { + const appActionsSource = read('js/presentation/app-actions.js'); + const intervalCallbacks = new Map(); + let nextIntervalId = 0; + let messageHandler = null; + const practiceWindow = { + closed: false, + focus() {}, + postMessage() {}, + location: { href: 'about:blank' } + }; + const openCalls = []; + const examWindows = new Map(); + const exams = [{ + id: 'reading-endless', + type: 'reading', + hasHtml: true, + title: 'Endless Reading' + }]; + const windowStub = { + location: { href: 'https://example.test/index.html' }, + document: { + readyState: 'loading', + addEventListener() {}, + querySelector() { return null; } + }, + resolveActiveLibraryIndex: async () => exams, + showMessage() {}, + addEventListener(type, listener) { + if (type === 'message') messageHandler = listener; + }, + removeEventListener(type, listener) { + if (type === 'message' && messageHandler === listener) messageHandler = null; + }, + app: { + examWindows, + async openExam(examId, options) { + openCalls.push({ examId, options }); + examWindows.set(examId, { + expectedOrigin: 'https://example.test', + allowOpaqueOrigin: false, + windowSessionToken: 'endless-token' + }); + return practiceWindow; + }, + _postExamMessage() { return true; } + } + }; + const context = vm.createContext({ + window: windowStub, + document: windowStub.document, + console, + URL, + Promise, + Math, + Date, + setInterval(callback) { + const id = ++nextIntervalId; + intervalCallbacks.set(id, callback); + return id; + }, + clearInterval(id) { + intervalCallbacks.delete(id); + } + }); + vm.runInContext(appActionsSource, context, { filename: 'app-actions.js' }); + + await windowStub.AppActions.startEndlessPractice(); + ok(openCalls.length === 1, 'endless_first_open_not_called', failed); + ok(openCalls[0]?.options?.endlessMode === true, 'endless_first_open_missing_mode', failed); + ok(openCalls[0]?.options?.windowName === 'ielts-endless-mode-tab', 'endless_first_open_missing_stable_window_name', failed); + ok(typeof messageHandler === 'function', 'endless_message_handler_not_installed', failed); + + messageHandler?.({ + source: practiceWindow, + origin: 'https://example.test', + data: { + type: 'PRACTICE_COMPLETE', + source: 'practice_page', + data: { windowSessionToken: 'endless-token' } + } + }); + const countdownId = Math.max(...intervalCallbacks.keys()); + for (let tick = 0; tick < 5; tick += 1) { + intervalCallbacks.get(countdownId)?.(); + } + await Promise.resolve(); + await Promise.resolve(); + ok(openCalls.length === 2, 'endless_next_exam_did_not_use_openExam', failed); + ok(openCalls[1]?.options?.reuseWindow === practiceWindow, 'endless_next_exam_did_not_reuse_window', failed); + ok(openCalls[1]?.options?.endlessMode === true, 'endless_next_exam_missing_mode', failed); + windowStub.AppActions.stopEndlessPractice({ silent: true }); +} + +async function run() { const failed = []; const unifiedHtml = read('assets/generated/reading-exams/reading-practice-unified.html'); const unifiedPage = read('js/runtime/unifiedReadingPage.js'); @@ -24,6 +119,11 @@ function run() { ok(!/practice-page-ui\.js/.test(unifiedHtml), 'unified_html_loads_practice_page_ui', failed); ok(!/leftHtmlWithHighlights/.test(unifiedPage), 'unified_page_contains_leftHtmlWithHighlights', failed); ok(/function enterSubmittedReadOnlyState\s*\(/.test(unifiedPage), 'missing_enterSubmittedReadOnlyState', failed); + ok(/function setTimerLockMode\s*\([\s\S]*data-note-outline-add[\s\S]*disabled/.test(unifiedPage), 'timer_lock_does_not_disable_note_controls', failed); + ok(/function canEditReadingNotes\s*\(\)\s*\{\s*if \(state\.timerLocked\) return false;/.test(unifiedPage), 'can_edit_notes_allows_timer_lock', failed); + ok(/function upsertNote\s*\([\s\S]*if \(!canEditReadingNotes\(\)\) return null;/.test(unifiedPage), 'note_upsert_not_guarded_by_timer_lock', failed); + ok(/function syncReadingAnnotation\s*\([\s\S]*if \(!canEditReadingNotes\(\)\) return;/.test(unifiedPage), 'annotation_sync_not_guarded_by_timer_lock', failed); + ok(/function canSyncReadingDraft\s*\([\s\S]*!state\.timerLocked/.test(unifiedPage), 'draft_sync_not_guarded_by_timer_lock', failed); ok(/dom\.exitBtn\?\.addEventListener\('click',\s*handleExitClick\)/.test(unifiedPage), 'missing_exit_btn_binding', failed); ok(/ENDLESS_USER_EXIT/.test(unifiedPage), 'missing_endless_exit_message', failed); ok(/stopEndlessPractice/.test(unifiedPage), 'missing_endless_stop_function', failed); @@ -33,6 +133,7 @@ function run() { ok(/displayAnswerValue\(entry\.userAnswer\)/.test(unifiedPage), 'review_results_user_answer_not_normalized', failed); ok(/displayAnswerValue\(entry\.correctAnswer,\s*''\)/.test(unifiedPage), 'review_results_correct_answer_not_normalized', failed); ok(/setDropzoneAnswer\(dropzone,\s*value,\s*label\)/.test(unifiedPage), 'dropzone_replay_label_not_preserved', failed); + ok(/value:\s*item\.dataset\.heading\s*\|\|\s*item\.dataset\.option\s*\|\|\s*item\.dataset\.key/.test(unifiedPage), 'drag_payload_ignores_data_key', failed); ok(/const valueList = splitAnswerTokens\(rawValue\);/.test(unifiedPage), 'replay_field_value_list_not_normalized', failed); ok(!/String\(rawValue == null \? '' : rawValue\)\.split/.test(unifiedPage), 'replay_raw_object_string_split_regressed', failed); ok(/--reading-left-pane-width/.test(unifiedHtml), 'missing_resizable_reading_pane_width_var', failed); @@ -52,6 +153,7 @@ function run() { ok(/#right \.tfng-item > p\s*\{[\s\S]*margin:\s*0 0 6px/.test(unifiedHtml), 'tfng_stem_option_spacing_not_scoped', failed); ok(/\.tfng-options\s*\{[\s\S]*gap:\s*4px 12px/.test(unifiedHtml), 'tfng_option_row_spacing_missing', failed); ok(/function restoreHighlights\s*\([\s\S]*?return restoredCount;/.test(highlightShared), 'restoreHighlights_no_restore_count', failed); + await testEndlessLifecycle(failed); if (failed.length) { process.stdout.write(JSON.stringify({ @@ -68,4 +170,4 @@ function run() { })); } -run(); +await run(); diff --git a/developer/tests/js/unifiedReadingNotesMigration.test.js b/developer/tests/js/unifiedReadingNotesMigration.test.js new file mode 100644 index 00000000..dbe38998 --- /dev/null +++ b/developer/tests/js/unifiedReadingNotesMigration.test.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..', '..'); +const page = fs.readFileSync(path.join(root, 'js/runtime/unifiedReadingPage.js'), 'utf8'); +const highlights = fs.readFileSync(path.join(root, 'js/runtime/readingHighlightShared.js'), 'utf8'); + +assert.match(highlights, /noteId:\s*node\.dataset/); +assert.match(highlights, /offsetSpan\.dataset\.noteId\s*=\s*String\(record\.noteId\)/); +assert.match(highlights, /span\.dataset\.noteId\s*=\s*String\(record\.noteId\)/); + +for (const [field, collector] of [['notes', 'collectNotes'], ['noteOutlines', 'collectNoteOutlines'], ['markedQuestions', 'getCurrentMarkedQuestions']]) { + assert.match(page, new RegExp(`${field}: ${collector}`, 'm'), `${field} must be collected into drafts/submissions`); + assert.match(page, new RegExp(`${field}: normalize`, 'm'), `${field} must be normalized at payload boundaries`); +} + +assert.match(page, /if \(state\.reviewMode\) \{[\s\S]*postMessage\('READING_ANNOTATION_SYNC'/); +assert.match(page, /postMessage\('READING_DRAFT_SYNC'/); +assert.match(page, /function canSyncReadingDraft\(\)/); +assert.match(page, /attachReadingDraftLifecycleHooks/); +assert.match(page, /recordId:\s*state\.reviewRecordId/); +assert.match(page, /annotations:\s*\{[\s\S]*highlights:[\s\S]*noteText:[\s\S]*notes:[\s\S]*noteOutlines:[\s\S]*markedQuestions:[\s\S]*scrollY:/); +assert.match(page, /state\.reviewRecordId\s*=\s*String\(data\.recordId \|\| entry\.id/); +assert.match(page, /function canEditReadingNotes\(\)[\s\S]*!state\.readOnly[\s\S]*!state\.timerLocked[\s\S]*!state\.memorizeMode[\s\S]*!state\.submitted/); +assert.match(page, /const canEditNotes = canEditReadingNotes\(\)/); +assert.match(page, /control\.closest\('#reading-note-editor, #reading-note-drawer'\)/); +assert.match(page, /syncReadingAnnotation\('highlight'\)/); +assert.match(page, /function clearStructuredNotesForReset\(\)[\s\S]*\.hl\[data-note-id\], \.hl\[data-hl-type="note"\]/); +assert.match(page, /#reading-note-drawer\{[^}]*z-index:3600/); +assert.match(page, /#reading-note-editor\{[^}]*z-index:3700/); +assert.match(page, /data-result-question-id/); +assert.match(page, /displayUserAnswer:\s*selectedTokens\.length/); + +assert.doesNotMatch(page, /saveLocalReadingRecord|ExamSystemDB|exam_system_practice_records|indexedDB/i); + +console.log(JSON.stringify({ + status: 'pass', + detail: 'structured notes, note anchors, review sync, display controls and safe storage boundaries covered' +})); diff --git a/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js b/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js index 68c0a87a..d9e0710c 100644 --- a/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js +++ b/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js @@ -15,18 +15,20 @@ function loadScript(relativePath, context) { vm.runInContext(code, context, { filename: relativePath }); } -function createSessionStorageStub() { +function createWindowSessionStub() { const store = new Map(); return { - store, - getItem(key) { - return store.has(key) ? store.get(key) : null; + save(name, value) { + store.set(String(name), JSON.parse(JSON.stringify(value))); + return true; }, - setItem(key, value) { - store.set(key, String(value)); + get(name) { + const value = store.get(String(name)); + return value == null ? null : JSON.parse(JSON.stringify(value)); }, - removeItem(key) { - store.delete(key); + discard(name) { + store.delete(String(name)); + return true; } }; } @@ -64,17 +66,29 @@ function createDocumentStub() { }; } +function hostEvent(sourceWindow, type, data, overrides = {}) { + return { + source: overrides.source || sourceWindow, + origin: overrides.origin || 'http://localhost', + data: { type, source: overrides.envelopeSource || 'exam_host', data } + }; +} + function createContext() { - const sessionStorage = createSessionStorageStub(); + const windowSession = createWindowSessionStub(); const document = createDocumentStub(); const window = { location: { href: 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1', - search: '?examId=reading-p1' + search: '?examId=reading-p1', + protocol: 'http:' }, history: { replaceState() {} }, document, - sessionStorage, + AppData: { + ready: Promise.resolve(true), + recovery: { windowSession } + }, opener: null, parent: null, addEventListener() {}, @@ -132,22 +146,21 @@ function createContext() { HTMLInputElement: window.HTMLInputElement, HTMLTextAreaElement: window.HTMLTextAreaElement, HTMLSelectElement: window.HTMLSelectElement, - sessionStorage, location: window.location }; sandbox.globalThis = window; - return { context: vm.createContext(sandbox), window, document, sessionStorage }; + return { context: vm.createContext(sandbox), window, document, windowSession }; } function loadHooks() { - const { context, window, sessionStorage } = createContext(); + const { context, window, windowSession } = createContext(); window.__IELTS_READING_PAGE_TEST_HOOKS__ = true; window.__READING_EXAM_MANIFEST__ = {}; window.__READING_EXAM_DATA__ = new Map(); loadScript('js/runtime/unifiedReadingPage.js', context); const hooks = window.__IELTS_UNIFIED_READING_PAGE_TEST__; assert(hooks, 'should expose unified reading page test hooks'); - return { hooks, window, sessionStorage }; + return { hooks, window, windowSession }; } function plain(value) { @@ -231,7 +244,7 @@ async function testInlineEnvelopeGuard() { } async function testInlineReinitSnapshot() { - const { hooks, sessionStorage, window } = loadHooks(); + const { hooks, windowSession, window } = loadHooks(); hooks.setTestState({ examId: 'reading-p1', @@ -284,9 +297,8 @@ async function testInlineReinitSnapshot() { assert.deepStrictEqual(plain(slotEntry[1].draft.answers), { q1: 'A' }, 'slot draft must be updated before reinit'); assert.strictEqual(slotEntry[1].draft.noteText, 'fresh note', 'slot draft noteText must be updated before reinit'); - const storageKey = 'ielts_sim_draft::suite-1::reading-p1'; - assert(sessionStorage.store.has(storageKey), 'reinit snapshot must persist the local mirror'); - const stored = JSON.parse(sessionStorage.store.get(storageKey)); + const stored = windowSession.get('simulation-draft:suite-1:reading-p1'); + assert(stored, 'reinit snapshot must persist the window-session draft'); assert.deepStrictEqual(plain(stored.draft.answers), { q1: 'A' }, 'persisted mirror must use the captured draft'); } @@ -299,6 +311,9 @@ async function testWindowSessionMessageGuard() { sessionId: 'session-new', suiteSessionId: 'suite-new', parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, windowSessionToken: 'token-new', windowSessionIssuedAtMs: 5000, lastInitSignature: '', @@ -316,29 +331,20 @@ async function testWindowSessionMessageGuard() { } }); - await hooks.handleIncoming({ - source: sourceWindow, - data: { - type: 'INIT_SESSION', - data: { + await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { examId: 'reading-p2', sessionId: 'session-new', suiteSessionId: 'suite-new', windowSessionToken: 'token-old', - messageIssuedAtMs: 4000 - } - } - }); + messageIssuedAtMs: 4000, + parentOrigin: 'http://localhost' + })); let state = hooks.getTestState(); assert.strictEqual(state.lastInitSignature, '', 'stale INIT_SESSION must not overwrite current inline session'); assert.strictEqual(state.windowSessionToken, 'token-new', 'stale INIT_SESSION must not replace window token'); - await hooks.handleIncoming({ - source: sourceWindow, - data: { - type: 'SIMULATION_CONTEXT', - data: { + await hooks.handleIncoming(hostEvent(sourceWindow, 'SIMULATION_CONTEXT', { examId: 'reading-p2', sessionId: 'session-new', suiteSessionId: 'suite-new', @@ -352,30 +358,322 @@ async function testWindowSessionMessageGuard() { { examId: 'reading-p2' }, { examId: 'reading-p3' } ] - } - } - }); + })); state = hooks.getTestState(); assert.strictEqual(state.simulationCtx.currentIndex, 1, 'stale SIMULATION_CONTEXT must not replace active simulation context'); - await hooks.handleIncoming({ - source: sourceWindow, - data: { - type: 'INIT_SESSION', - data: { + await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { examId: 'reading-p2', sessionId: 'session-newer', suiteSessionId: 'suite-new', windowSessionToken: 'token-newer', - messageIssuedAtMs: 6000 - } - } - }); + messageIssuedAtMs: 6000, + parentOrigin: 'http://localhost' + })); state = hooks.getTestState(); assert.strictEqual(state.sessionId, 'session-newer', 'newer INIT_SESSION must still be accepted'); assert.strictEqual(state.windowSessionToken, 'token-newer', 'newer INIT_SESSION must adopt the latest window token'); + hooks.stopReadingDraftSync(); + hooks.stopSimulationDraftSync(); +} + +async function testReferrerlessInitBindsOnlyTrustedHost() { + const { hooks } = loadHooks(); + const parentWindow = { postMessage() {} }; + hooks.setTestState({ + examId: 'reading-p1', + sessionId: null, + suiteSessionId: null, + parentWindow, + expectedParentOrigin: '', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + lastInitSignature: '' + }); + const initData = { + examId: 'reading-p1', + sessionId: 'referrerless-session', + parentOrigin: 'https://host.example', + windowSessionToken: 'referrerless-token' + }; + await hooks.handleIncoming(hostEvent({ postMessage() {} }, 'INIT_SESSION', initData, { origin: 'https://host.example' })); + assert.strictEqual(hooks.getTestState().parentOrigin, '', 'a forged source must not bind a referrerless child'); + await hooks.handleIncoming(hostEvent(parentWindow, 'INIT_SESSION', initData, { origin: 'https://attacker.invalid' })); + assert.strictEqual(hooks.getTestState().parentOrigin, '', 'a mismatched origin must not bind a referrerless child'); + await hooks.handleIncoming(hostEvent(parentWindow, 'INIT_SESSION', initData, { origin: 'https://host.example' })); + assert.strictEqual(hooks.getTestState().parentOrigin, 'https://host.example', 'trusted non-opaque INIT must bind the missing referrer origin'); + assert.strictEqual(hooks.getTestState().windowSessionToken, 'referrerless-token'); + + const fileHarness = loadHooks(); + const fileParent = { postMessage() {} }; + fileHarness.window.location.protocol = 'file:'; + fileHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: null, + suiteSessionId: null, + parentWindow: fileParent, + expectedParentOrigin: '', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + lastInitSignature: '' + }); + await fileHarness.hooks.handleIncoming(hostEvent(fileParent, 'INIT_SESSION', { + examId: 'reading-p1', + sessionId: 'file-session', + parentOrigin: 'file://', + windowSessionToken: 'file-token' + }, { origin: 'file://' })); + assert.strictEqual(fileHarness.hooks.getTestState().parentOrigin, 'null', + 'file:// opener INIT must bind the opaque origin'); + assert.strictEqual(fileHarness.hooks.getTestState().parentOriginIsOpaque, true); + assert.strictEqual(fileHarness.hooks.getTestState().windowSessionToken, 'file-token'); +} + +async function testSavedRecordAcknowledgementSessionGate() { + const { hooks } = loadHooks(); + const sourceWindow = { name: 'saved-record-host' }; + hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-current', + submittedRecordId: 'record-existing', + parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-current', + suite: { + inline: false, + slotsByExamId: new Map() + } + }); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + examId: 'reading-p1', + sessionId: 'session-stale', + recordId: 'record-stale', + windowSessionToken: 'token-current' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-existing', + 'a late acknowledgement from an older session must be ignored' + ); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + examId: 'reading-p1', + recordId: 'record-without-session', + windowSessionToken: 'token-current' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-existing', + 'an acknowledgement without a session binding must be ignored' + ); + + const validAcknowledgement = { + examId: 'reading-p1', + sessionId: 'session-current', + recordId: 'record-current', + windowSessionToken: 'token-current' + }; + await hooks.handleIncoming(hostEvent( + sourceWindow, + 'PRACTICE_RECORD_SAVED', + validAcknowledgement, + { origin: 'https://attacker.invalid' } + )); + await hooks.handleIncoming(hostEvent( + sourceWindow, + 'PRACTICE_RECORD_SAVED', + validAcknowledgement, + { source: { name: sourceWindow.name } } + )); + await hooks.handleIncoming(hostEvent( + sourceWindow, + 'PRACTICE_RECORD_SAVED', + validAcknowledgement, + { envelopeSource: 'practice_page' } + )); + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + ...validAcknowledgement, + windowSessionToken: 'token-forged' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-existing', + 'wrong origin/window/source/token must not overwrite the saved record binding' + ); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + ...validAcknowledgement + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-current', + 'the current session acknowledgement should bind its saved record id' + ); + + hooks.setTestState({ sessionId: null }); + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + examId: 'reading-p1', + sessionId: 'session-current', + recordId: 'record-during-restart', + windowSessionToken: 'token-current' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-current', + 'an acknowledgement received during session restart must be ignored' + ); +} + +async function testSubmitAcknowledgementStateMachine() { + const falseHarness = loadHooks(); + falseHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-false', + parentWindow: { postMessage() { return false; } }, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-false', + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual(falseHarness.hooks.beginSubmission('PRACTICE_COMPLETE', {}), false); + assert.strictEqual(falseHarness.hooks.getTestState().submissionStatus, 'draft'); + assert.strictEqual(falseHarness.hooks.getTestState().readOnly, false); + + const failedHarness = loadHooks(); + const throwingParent = { + postMessage() { + throw new Error('delivery failed'); + } + }; + failedHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-failed', + parentWindow: throwingParent, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-failed', + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual( + failedHarness.hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), + false, + 'a synchronous postMessage failure must reject the submission attempt' + ); + assert.strictEqual(failedHarness.hooks.getTestState().submissionStatus, 'draft'); + assert.strictEqual(failedHarness.hooks.getTestState().readOnly, false); + + const delivered = []; + const sourceWindow = { + postMessage(message) { + delivered.push(message); + } + }; + const { hooks } = loadHooks(); + hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-current', + suiteSessionId: null, + parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-current', + submissionStatus: 'draft', + submissionId: '' + }); + + assert.strictEqual(hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), true); + let state = hooks.getTestState(); + const submissionId = state.submissionId; + assert.strictEqual(state.submissionStatus, 'submitting'); + assert.strictEqual(state.submitted, false, 'delivery alone must not mark the page submitted'); + assert.strictEqual(state.readOnly, false, 'delivery alone must not lock the page'); + assert.strictEqual(delivered.length, 1); + assert.strictEqual(delivered[0].data.submissionId, submissionId); + assert.strictEqual( + hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), + false, + 'a duplicate click while submitting must be ignored' + ); + assert.strictEqual(delivered.length, 1, 'duplicate clicks must not emit a second message'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', { + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + assert.strictEqual(hooks.getTestState().submissionStatus, 'submitting', 'ACK without examId must be ignored'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_FAILED', { + examId: 'reading-p1', + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + state = hooks.getTestState(); + assert.strictEqual(state.submissionStatus, 'draft'); + assert.strictEqual(state.readOnly, false); + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p1', + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + assert.strictEqual(hooks.getTestState().submissionStatus, 'draft', 'late ACK after NACK must not submit the page'); + assert.strictEqual(hooks.getTestState().readOnly, false); + + assert.strictEqual(hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), true); + assert.strictEqual(delivered.length, 2); + assert.strictEqual(delivered[1].data.submissionId, submissionId, 'retry must reuse the idempotency key'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p1', + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + state = hooks.getTestState(); + assert.strictEqual(state.submissionStatus, 'submitted'); + assert.strictEqual(state.submitted, true); + assert.strictEqual(state.readOnly, true, 'only a valid ACK may lock the page'); + + const timeoutHarness = loadHooks(); + const timeoutParent = { postMessage() {} }; + timeoutHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-timeout', + parentWindow: timeoutParent, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-timeout', + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual(timeoutHarness.hooks.beginSubmission('PRACTICE_COMPLETE', {}), true); + const timeoutSubmissionId = timeoutHarness.hooks.getTestState().submissionId; + assert.strictEqual(timeoutHarness.hooks.expirePendingSubmission(timeoutSubmissionId), true); + assert.strictEqual(timeoutHarness.hooks.getTestState().submissionStatus, 'draft'); + assert.strictEqual(timeoutHarness.hooks.getTestState().readOnly, false); + await timeoutHarness.hooks.handleIncoming(hostEvent(timeoutParent, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p1', + sessionId: 'session-submit-timeout', + submissionId: timeoutSubmissionId, + windowSessionToken: 'token-submit-timeout' + })); + assert.strictEqual(timeoutHarness.hooks.getTestState().submissionStatus, 'draft', 'late ACK after timeout must be ignored'); + assert.strictEqual(timeoutHarness.hooks.getTestState().readOnly, false); } async function main() { @@ -383,10 +681,14 @@ async function main() { await testInlineEnvelopeGuard(); await testInlineReinitSnapshot(); await testWindowSessionMessageGuard(); + await testReferrerlessInitBindsOnlyTrustedHost(); + await testSavedRecordAcknowledgementSessionGate(); + await testSubmitAcknowledgementStateMachine(); process.stdout.write(JSON.stringify({ status: 'pass', detail: 'unified reading inline suite regressions covered' })); + process.exit(0); } main().catch((error) => { diff --git a/developer/tests/js/vocabDataIO.test.js b/developer/tests/js/vocabDataIO.test.js new file mode 100644 index 00000000..b3369973 --- /dev/null +++ b/developer/tests/js/vocabDataIO.test.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +global.window = global; +global.AppData = { + ready: Promise.resolve(), + vocab: { + async getConfig() { + return { activeListId: 'spelling-errors-p1', dailyNew: 8 }; + }, + async readList(listId) { + assert.strictEqual(listId, 'spelling-errors-p1'); + return { + id: listId, + words: [{ id: 'word-1', word: 'garden', meaning: '花园' }] + }; + } + } +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../..'); +eval(fs.readFileSync(path.join(repoRoot, 'js/utils/vocabDataIO.js'), 'utf8')); + +const importBlob = new Blob([JSON.stringify({ + version: '2.0', + listId: 'spelling-errors-p1', + config: { activeListId: 'spelling-errors-p1', dailyNew: 8 }, + reviewQueue: ['legacy-derived-id'], + words: [{ id: 'word-1', word: 'garden', meaning: '花园', nextReview: '2026-07-25T00:00:00.000Z' }] +})], { type: 'application/json' }); +Object.defineProperty(importBlob, 'name', { value: 'progress.json' }); + +const imported = await window.VocabDataIO.importWordList(importBlob); +assert.strictEqual(imported.type, 'progress'); +assert.strictEqual(imported.meta.listId, 'spelling-errors-p1'); +assert.strictEqual(imported.meta.reviewQueue, undefined, 'derived review queue must not cross the import boundary'); +assert.strictEqual(imported.entries[0].nextReview, '2026-07-25T00:00:00.000Z'); + +const exported = JSON.parse(await (await window.VocabDataIO.exportProgress()).text()); +assert.strictEqual(exported.listId, 'spelling-errors-p1'); +assert.strictEqual(exported.words[0].word, 'garden'); +assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'reviewQueue'), false); + +console.log(JSON.stringify({ + status: 'pass', + detail: 'vocab progress import/export preserves canonical list identity and excludes derived queue' +}, null, 2)); diff --git a/developer/tests/js/vocabStore.test.js b/developer/tests/js/vocabStore.test.js index cb80d4fa..a32f5b3d 100644 --- a/developer/tests/js/vocabStore.test.js +++ b/developer/tests/js/vocabStore.test.js @@ -9,42 +9,100 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..', '..', '..'); -function createLocalStorage(seed = {}) { - const store = new Map(Object.entries(seed)); - return { - getItem(key) { - return store.has(key) ? store.get(key) : null; +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function createVocabFacade(seed = {}) { + const state = { + words: clone(seed.words || []), + collections: clone(seed.collections || {}), + config: { activeListId: 'default', ...(clone(seed.config) || {}) } + }; + const vocab = { + async getConfig() { + return clone(state.config); + }, + async listWords() { + return clone(state.words); }, - setItem(key, value) { - store.set(key, String(value)); + async listCollections() { + return clone(state.collections); }, - removeItem(key) { - store.delete(key); + async replaceListWords({ listId = 'default', words = [] }) { + if (seed.failReplace) throw new Error('backend write failed'); + if (listId === 'default') { + state.words = clone(words); + } else { + state.collections[listId] = { + ...(state.collections[listId] || {}), + id: listId, + words: clone(words) + }; + } + return { committed: true }; }, - clear() { - store.clear(); + async mergeListWords({ listId = 'default', words = [] }) { + const target = listId === 'default' + ? state.words + : (state.collections[listId]?.words || []); + const merged = clone(target); + let addedCount = 0; + let updatedCount = 0; + for (const incoming of words) { + const identity = String(incoming.word || incoming.id || '').trim().toLowerCase(); + const index = merged.findIndex((word) => String(word.word || word.id || '').trim().toLowerCase() === identity); + if (index >= 0) { + merged[index] = { ...merged[index], ...clone(incoming) }; + updatedCount += 1; + } else { + merged.push(clone(incoming)); + addedCount += 1; + } + } + await this.replaceListWords({ listId, words: merged }); + return { committed: true, words: clone(merged), addedCount, updatedCount }; + }, + async patchConfig(patch = {}) { + state.config = { ...state.config, ...clone(patch) }; + return { committed: true }; + }, + async activateList(listId) { + state.config = { ...state.config, activeListId: listId }; + return { committed: true }; + }, + async patchWord({ listId = 'default', wordId, patch = {} }) { + const source = listId === 'default' + ? state.words + : (state.collections[listId]?.words || []); + const index = source.findIndex((word) => (word.id || word.word) === wordId); + if (index < 0) throw new Error(`Unknown word: ${wordId}`); + source[index] = { ...source[index], ...clone(patch) }; + return { committed: true, word: clone(source[index]) }; } }; + return { state, vocab }; } -function loadVocabStore({ embeddedWords, storageSeed }) { +function loadVocabStore({ embeddedWords, dataSeed }) { const quietConsole = { log() {}, warn() {}, error() {}, info() {} }; + const { state: appDataState, vocab } = createVocabFacade(dataSeed); const windowStub = { console: quietConsole, __EMBEDDED_WORDLISTS__: { ielts_core: embeddedWords || [] }, - location: { protocol: 'file:' } + location: { protocol: 'file:' }, + AppData: { ready: Promise.resolve(), vocab } }; const sandbox = { window: windowStub, console: quietConsole, - localStorage: createLocalStorage(storageSeed), Date, Math, JSON, @@ -52,7 +110,6 @@ function loadVocabStore({ embeddedWords, storageSeed }) { clearTimeout }; sandbox.globalThis = sandbox.window; - sandbox.window.localStorage = sandbox.localStorage; sandbox.window.Date = Date; sandbox.window.Math = Math; sandbox.window.JSON = JSON; @@ -62,6 +119,7 @@ function loadVocabStore({ embeddedWords, storageSeed }) { const context = vm.createContext(sandbox); const source = fs.readFileSync(path.join(repoRoot, 'js/core/vocabStore.js'), 'utf8'); vm.runInContext(source, context, { filename: 'js/core/vocabStore.js' }); + sandbox.window.VocabStore.__appDataState = appDataState; return sandbox.window.VocabStore; } @@ -72,9 +130,11 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() { meaning: 'n. 住宿', example: 'The hotel provides comfortable accommodation.' }], - storageSeed: { - vocab_list_p1_errors: JSON.stringify({ - id: 'p1', + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-p1': { + id: 'spelling-errors-p1', words: [{ word: 'accommodation', userInput: 'accomodation', @@ -84,7 +144,8 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() { errorCount: 2, source: 'p1' }] - }) + } + } } }); @@ -102,9 +163,11 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() { async function testSpellingErrorFallsBackWhenLexiconMissing() { const vocabStore = loadVocabStore({ embeddedWords: [], - storageSeed: { - vocab_list_p4_errors: JSON.stringify({ - id: 'p4', + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-p4': { + id: 'spelling-errors-p4', words: [{ word: 'specialised', userInput: 'specializedd', @@ -114,7 +177,8 @@ async function testSpellingErrorFallsBackWhenLexiconMissing() { errorCount: 1, source: 'p4' }] - }) + } + } } }); @@ -130,8 +194,12 @@ async function testSpellingErrorFallsBackWhenLexiconMissing() { async function testSpellingErrorPreservesStoredMeaningAndMetadata() { const vocabStore = loadVocabStore({ embeddedWords: [], - storageSeed: { - vocab_list_master_errors: JSON.stringify([{ + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-master': { + id: 'spelling-errors-master', + words: [{ id: 'spelling-all-garden', word: 'garden', meaning: 'n. 花园;庭院', @@ -145,7 +213,9 @@ async function testSpellingErrorPreservesStoredMeaningAndMetadata() { acceptedAnswers: ['green garden', 'green gardens'], canonicalAnswer: 'green garden', reasonCode: 'edit' - }]) + }] + } + } } }); @@ -168,8 +238,12 @@ async function testSpellingErrorPreservesStoredMeaningAndMetadata() { async function testSpellingErrorMetadataSurvivesStudyUpdates() { const vocabStore = loadVocabStore({ embeddedWords: [], - storageSeed: { - vocab_list_master_errors: JSON.stringify([{ + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-master': { + id: 'spelling-errors-master', + words: [{ id: 'spelling-all-garden', word: 'garden', meaning: 'n. 花园;庭院', @@ -181,7 +255,9 @@ async function testSpellingErrorMetadataSurvivesStudyUpdates() { source: 'p1', acceptedAnswers: ['green garden'], canonicalAnswer: 'green garden' - }]) + }] + } + } } }); @@ -199,6 +275,22 @@ async function testSpellingErrorMetadataSurvivesStudyUpdates() { assert.strictEqual(updated.errorCount, 3, '背诵更新不应该洗掉错误次数'); assert.deepStrictEqual(updated.acceptedAnswers, ['green garden']); assert.strictEqual(updated.canonicalAnswer, 'green garden'); + assert.strictEqual(vocabStore.__appDataState.config.activeListId, 'spelling-errors-master'); + assert.strictEqual( + vocabStore.__appDataState.collections['spelling-errors-master'].words[0].note, + 'new memory note', + '学习更新必须通过 AppData.vocab.patchWord 提交' + ); +} + +async function testDefaultLexiconWriteFailureRejectsInitialization() { + const vocabStore = loadVocabStore({ + embeddedWords: [{ word: 'alpha', meaning: 'A' }], + dataSeed: { failReplace: true } + }); + + await assert.rejects(vocabStore.init(), /backend write failed/); + assert.strictEqual(vocabStore.state.ready, false, '持久化失败时不得把词汇域标记为 ready'); } async function main() { @@ -212,6 +304,8 @@ async function main() { results.push({ name: '错词保留已补全释义和元数据', status: 'pass' }); await testSpellingErrorMetadataSurvivesStudyUpdates(); results.push({ name: '背诵更新保留错词业务元数据', status: 'pass' }); + await testDefaultLexiconWriteFailureRejectsInitialization(); + results.push({ name: '默认词库持久化失败会阻断 ready', status: 'pass' }); console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} 测试通过`, diff --git a/developer/tests/performance-test.html b/developer/tests/performance-test.html deleted file mode 100644 index 22706abe..00000000 --- a/developer/tests/performance-test.html +++ /dev/null @@ -1,725 +0,0 @@ - - - - - - 性能基线测量工具 - - - -
-

📊 性能基线测量工具

-

测量系统性能指标,建立性能基线,识别优化机会

- -
-

🎯 测试控制面板

- -
- - - - - - -
- -
-
-
-
准备就绪
- -
- 实时指标: - FPS: -- - 内存: -- - 延迟: -- -
-
- -
-

📈 性能指标

- -
- -
-
- -
-

🎯 性能建议

-
-

运行测试后将显示性能优化建议...

-
-
- -
-

📊 详细报告

-
运行测试后将显示详细的性能报告...
-
- -
-

🔧 高级测试

- -
- - - - -
- -
-
-
- - - - - - - - - - - - - - - - diff --git a/developer/tests/refresh-test.html b/developer/tests/refresh-test.html deleted file mode 100644 index f42fb82f..00000000 --- a/developer/tests/refresh-test.html +++ /dev/null @@ -1,408 +0,0 @@ - - - - - - 状态序列化刷新测试 - - - -
-

🔄 状态序列化刷新测试

-

测试Set/Map对象在页面刷新后的持久化能力

- -
-

📋 测试步骤:

-
    -
  1. 点击"创建测试数据" - 创建包含Set/Map的测试状态
  2. -
  3. 点击"保存状态到存储" - 序列化并保存到localStorage
  4. -
  5. 刷新页面 (F5) - 模拟用户刷新行为
  6. -
  7. 点击"验证恢复的状态" - 检查数据完整性
  8. -
- -
- - - - - - - -
-
- -
-

📊 测试结果:

-
-
点击上方按钮开始测试
-
-
- -
-

📋 当前状态:

-
无状态数据
-
- -
-

🔍 存储检查:

-
- -

-            
-
-
- - - - - - - - - - - - - - - - - - diff --git a/developer/tests/regression-test.html b/developer/tests/regression-test.html deleted file mode 100644 index b30c4b80..00000000 --- a/developer/tests/regression-test.html +++ /dev/null @@ -1,625 +0,0 @@ - - - - - - 本地回归测试套件 - - - -
-

🧪 本地回归测试套件

-

自动化测试核心功能,确保系统稳定性和数据完整性

- -
-

🎯 测试控制面板

- -
- - - - - - -
- -
-
-
-
准备就绪
-
- -
-

📊 测试结果汇总

- -
-
-
0
-
总测试数
-
-
-
0
-
通过测试
-
-
-
0
-
失败测试
-
-
-
0%
-
成功率
-
-
- -
-
- -
-

📋 详细测试报告

-
点击"运行所有测试"开始测试...
-
- -
-
-

Exam加载测试

-

验证考试数据加载、分类、搜索和UI渲染功能

- -
-
- -
-

Practice记录测试

-

验证练习记录的创建、读取、更新、删除和批量操作

- -
-
- -
-

备份恢复测试

-

验证数据备份创建、恢复、删除和完整性验证

- -
-
- -
-

状态序列化测试

-

验证Set/Map对象的序列化/反序列化和数据完整性

- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/developer/tests/run-integration-tests.bat b/developer/tests/run-integration-tests.bat index 053d624b..764bfaef 100644 --- a/developer/tests/run-integration-tests.bat +++ b/developer/tests/run-integration-tests.bat @@ -2,15 +2,7 @@ echo Running Integration Tests... echo. -echo Test 1: Multi-Suite Submission Flow -node developer\tests\js\integration\multiSuiteSubmission.test.js -if %ERRORLEVEL% NEQ 0 ( - echo FAILED: Multi-Suite Submission Test - exit /b 1 -) -echo. - -echo Test 2: Spelling Error Collection Flow +echo Test 1: Spelling Error Collection Flow node developer\tests\js\integration\spellingErrorCollection.test.js if %ERRORLEVEL% NEQ 0 ( echo FAILED: Spelling Error Collection Test @@ -18,7 +10,7 @@ if %ERRORLEVEL% NEQ 0 ( ) echo. -echo Test 3: Vocab List Switching Flow +echo Test 2: Vocab List Switching Flow node developer\tests\js\integration\vocabListSwitching.test.js if %ERRORLEVEL% NEQ 0 ( echo FAILED: Vocab List Switching Test @@ -26,7 +18,7 @@ if %ERRORLEVEL% NEQ 0 ( ) echo. -echo Test 4: Vocab Session View Flow +echo Test 3: Vocab Session View Flow node developer\tests\js\integration\vocabSessionView.test.js if %ERRORLEVEL% NEQ 0 ( echo FAILED: Vocab Session View Test @@ -35,16 +27,4 @@ if %ERRORLEVEL% NEQ 0 ( echo. echo All integration tests passed! -echo. - -echo Running Performance Benchmarks... -echo. -node developer\tests\js\integration\performance.benchmark.js -if %ERRORLEVEL% NEQ 0 ( - echo WARNING: Performance benchmark failed - echo Continuing anyway... -) -echo. - -echo All tests completed! exit /b 0 diff --git a/developer/tests/run_all_tests.py b/developer/tests/run_all_tests.py index dc72d71d..004cd5c7 100644 --- a/developer/tests/run_all_tests.py +++ b/developer/tests/run_all_tests.py @@ -163,62 +163,72 @@ def run_ci_tests(self) -> bool: return False def run_e2e_tests(self) -> bool: - """运行 E2E 测试""" + """运行统一 E2E 套件(file:// 兼容,含提交/结算与导出导入)""" self.log("=" * 80) - self.log("运行 E2E 套题练习流程测试") + self.log("运行统一 E2E 套件 (e2e_runner.py)") self.log("=" * 80) - - test_script = REPO_ROOT / "developer" / "tests" / "e2e" / "suite_practice_flow.py" - + + test_script = REPO_ROOT / "developer" / "tests" / "e2e" / "e2e_runner.py" + if not test_script.exists(): self.log(f"E2E 测试脚本不存在: {test_script}", "ERROR") return False - + try: result = subprocess.run( [sys.executable, str(test_script)], capture_output=True, text=True, - timeout=180 + timeout=900 ) - + print(result.stdout) if result.stderr: print(result.stderr) - + passed = result.returncode == 0 - - # 尝试解析 JSON 报告 - report_path = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" / "suite-practice-flow-report.json" + + report_path = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" / "e2e-unified-report.json" if report_path.exists(): try: report = json.loads(report_path.read_text(encoding="utf-8")) self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": report.get("status", "unknown"), - "duration": report.get("duration"), - "consoleLogs": len(report.get("consoleLogs", [])) + "duration": report.get("durationSeconds"), + "cases": [ + { + "name": item.get("name"), + "status": item.get("status"), + "exitCode": item.get("exitCode"), + } + for item in report.get("cases", []) + ], }) except Exception: - pass + self.results.append({ + "name": "E2E 统一套件", + "status": "pass" if passed else "fail", + "returnCode": result.returncode + }) else: self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": "pass" if passed else "fail", "returnCode": result.returncode }) - + if passed: self.log("E2E 测试通过", "SUCCESS") else: self.log(f"E2E 测试失败 (返回码: {result.returncode})", "ERROR") - + return passed - + except subprocess.TimeoutExpired: - self.log("E2E 测试超时 (180秒)", "ERROR") + self.log("E2E 测试超时 (900秒)", "ERROR") self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": "fail", "error": "超时" }) @@ -226,7 +236,7 @@ def run_e2e_tests(self) -> bool: except Exception as e: self.log(f"运行 E2E 测试时出错: {e}", "ERROR") self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": "fail", "error": str(e) }) diff --git a/developer/tests/storage-vocab-test.html b/developer/tests/storage-vocab-test.html deleted file mode 100644 index 0e5cae55..00000000 --- a/developer/tests/storage-vocab-test.html +++ /dev/null @@ -1,386 +0,0 @@ - - - - - - Storage Vocabulary Test - - - -

Storage Vocabulary Test

-

测试词表存储、同步和导出功能

- -
-

1. 词表存储测试

- - - - -
-
- -
-

2. 数据同步测试

- - - -
-
- -
-

3. 降级存储测试

- - -
-
- -
-

4. 数据导出测试

- - - - -
-
- - - - - diff --git a/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js b/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js index 50c04e1f..8d281211 100644 --- a/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js +++ b/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js @@ -21,13 +21,17 @@ function readText(filePath) { } function parseArgs(argv) { - const args = { examId: '', list: false }; + const args = { examId: '', list: false, all: false }; for (let i = 2; i < argv.length; i += 1) { const token = argv[i]; if (token === '--list') { args.list = true; continue; } + if (token === '--all') { + args.all = true; + continue; + } if (token === '--exam-id') { args.examId = (argv[i + 1] || '').trim(); i += 1; @@ -120,6 +124,23 @@ function pickManifestEntry(manifest, examId) { return null; } +function buildPayload(dataset, entry, fallbackExamId = '') { + return { + examId: dataset.examId || entry.examId || fallbackExamId, + questionOrder: Array.isArray(dataset.questionOrder) ? dataset.questionOrder : [], + answerKey: dataset.answerKey && typeof dataset.answerKey === 'object' ? dataset.answerKey : {}, + questionGroups: Array.isArray(dataset.questionGroups) ? dataset.questionGroups : [], + questionDisplayMap: dataset.questionDisplayMap && typeof dataset.questionDisplayMap === 'object' + ? dataset.questionDisplayMap + : {}, + meta: dataset.meta && typeof dataset.meta === 'object' ? dataset.meta : {}, + metaQuestionIntroHtml: dataset.meta && typeof dataset.meta.questionIntroHtml === 'string' + ? dataset.meta.questionIntroHtml + : '', + script: entry.script + }; +} + function main() { if (!fs.existsSync(MANIFEST_PATH)) { fail('reading_manifest_not_found'); @@ -134,6 +155,17 @@ function main() { return; } + if (args.all) { + const entries = buildEntryList(manifest); + const datasets = Object.fromEntries(entries.map((entry) => { + const dataset = loadDataset(context, registry, entry); + const payload = buildPayload(dataset, entry, entry.examId); + return [payload.examId, payload]; + })); + process.stdout.write(`${JSON.stringify({ entries, datasets })}\n`); + return; + } + if (!args.examId) { fail('missing_required_arg:--exam-id'); } @@ -144,20 +176,7 @@ function main() { } const dataset = loadDataset(context, registry, entry); - const payload = { - examId: dataset.examId || entry.examId || args.examId, - questionOrder: Array.isArray(dataset.questionOrder) ? dataset.questionOrder : [], - answerKey: dataset.answerKey && typeof dataset.answerKey === 'object' ? dataset.answerKey : {}, - questionGroups: Array.isArray(dataset.questionGroups) ? dataset.questionGroups : [], - questionDisplayMap: dataset.questionDisplayMap && typeof dataset.questionDisplayMap === 'object' - ? dataset.questionDisplayMap - : {}, - meta: dataset.meta && typeof dataset.meta === 'object' ? dataset.meta : {}, - metaQuestionIntroHtml: dataset.meta && typeof dataset.meta.questionIntroHtml === 'string' - ? dataset.meta.questionIntroHtml - : '', - script: entry.script - }; + const payload = buildPayload(dataset, entry, args.examId); process.stdout.write(`${JSON.stringify(payload)}\n`); } diff --git a/developer/tests/vocabListSwitcher.test.html b/developer/tests/vocabListSwitcher.test.html deleted file mode 100644 index b2bcc900..00000000 --- a/developer/tests/vocabListSwitcher.test.html +++ /dev/null @@ -1,614 +0,0 @@ - - - - - - VocabListSwitcher 单元测试 - - - -

🧪 VocabListSwitcher 单元测试

-

测试词表切换器组件的核心功能

- -
-

测试摘要

-
总测试数: 0
-
通过: 0
-
失败: 0
-
成功率: 0%
-
- -
-

控制面板

- - -
- -
-

1. 组件渲染测试

- -
-
-
- -
-

2. 词表切换测试

- -
-
-
- -
-

3. 词表计数更新测试

- -
-
- -
-

4. 用户偏好保存测试

- -
-
- -
-

5. 错误处理测试

- -
-
- -
-

6. 下拉菜单交互测试

- - -
- -
-

测试日志

-
-
- - - - - - - - - diff --git a/templates/ci-practice-fixtures/analysis-of-fear.html b/templates/ci-practice-fixtures/analysis-of-fear.html index 7271e99c..ea83bdb5 100644 --- a/templates/ci-practice-fixtures/analysis-of-fear.html +++ b/templates/ci-practice-fixtures/analysis-of-fear.html @@ -895,37 +895,6 @@

Questions 36–40

return; } console.log('[PracticeEnhancer] 开始初始化'); - try { - if (window.storage?.ready) { - await window.storage.ready; - } - - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[PracticeEnhancer] 已设置共享命名空间: exam_system'); - - setTimeout(async () => { - const testKey = 'namespace_test_practice'; - const testValue = 'test_value_practice_' + Date.now(); - try { - await window.storage.set(testKey, testValue); - const retrievedValue = await window.storage.get(testKey); - if (retrievedValue === testValue) { - console.log('✅ 练习页面命名空间设置验证成功: 存储和读取正常'); - } else { - console.warn('❌ 练习页面命名空间设置验证失败: 读取值不匹配'); - } - await window.storage.remove(testKey); - } catch (error) { - console.error('❌ 练习页面命名空间设置验证失败', error); - } - }, 1000); - } else { - console.warn('[PracticeEnhancer] 存储管理器未加载或setNamespace方法不可用'); - } - } catch (error) { - console.error('[PracticeEnhancer] 存储初始化失败,跳过命名空间设置', error); - } this.setupCommunication(); this.setupAnswerListeners(); this.extractCorrectAnswers(); // 新增:提取正确答案 @@ -1778,7 +1747,10 @@

Questions 36–40

}; try { - this.parentWindow.postMessage(message, '*'); + this.parentWindow.postMessage( + message, + window.location.protocol === 'file:' ? '*' : window.location.origin + ); console.log('[PracticeEnhancer] 消息已发送:', type); } catch (error) { console.error('[PracticeEnhancer] 发送消息失败:', error); @@ -1840,4 +1812,4 @@

Questions 36–40

} - \ No newline at end of file + From d0736d99bbe167ae2bb885131102fa447b14056f Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:35:07 +0800 Subject: [PATCH 13/18] build: enforce v2 bundles and CI verification --- .github/workflows/ci.yml | 44 ++++- developer/release.ps1 | 3 +- developer/release.sh | 3 +- scripts/build-bundles.mjs | 405 +++++++++++++++++++++++++++++++++++--- 4 files changed, 424 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9849a25c..787c4753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +26,54 @@ jobs: with: python-version: '3.x' - - name: Rebuild JS bundles and check for drift + - name: Install Playwright (Chromium) + run: | + npm ci --prefix developer + python -m pip install --upgrade pip + python -m pip install playwright==1.56.0 + npm --prefix developer exec -- playwright install --with-deps chromium + + - name: Check JS bundle drift # The committed files under js/bundles/ must match what the build script # produces from the committed sources. If this step fails, a source file # was edited without rebuilding its bundle (run `node scripts/build-bundles.mjs`). - run: node scripts/build-bundles.mjs && git diff --exit-code -- js/bundles/ + run: node scripts/build-bundles.mjs --check - name: Run JS test (practiceTimerPreferences) run: node developer/tests/js/practiceTimerPreferences.test.js + - name: Run DataKernel v2 local protocol tests + run: node developer/tests/js/dataKernelV2.test.js local + + - name: Run AppData v2 domain tests + run: node developer/tests/js/appDataV2.test.js + + - name: Run legacy migration brick regression + run: node developer/tests/js/legacyMigrationBrickRegression.test.js + + - name: Run v2 data-loss baseline tests + run: node --test developer/tests/js/dataLossBaseline.test.js + + - name: Run practice completion / persistence regressions + run: | + node developer/tests/js/practiceCompletionFlow.test.js + node developer/tests/js/practiceRecordPersistence.test.js + node developer/tests/js/practiceRecorder.test.js + node developer/tests/js/examPlaceholderReplay.test.js + + - name: Run security protocol regression tests + run: | + node developer/tests/js/listeningRecordBridgeParser.test.js + node developer/tests/js/listeningRecordBridgeProtocol.test.js + node developer/tests/js/readingAnnotationHostProtocol.test.js + node developer/tests/js/reviewHighlightDictionaryProtocol.test.js + node developer/tests/js/legacyViewReadStatus.test.js + node developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js + node developer/tests/js/suiteInlineFallback.test.js + node developer/tests/js/suiteModeRegression.test.js + - name: Run Python tests (listening asset quality) run: python -m unittest developer.tests.py.test_listening_generate_assets_quality + + - name: Run unified E2E suite (file:// capable) + run: python developer/tests/e2e/e2e_runner.py diff --git a/developer/release.ps1 b/developer/release.ps1 index 2f87a6ef..fc5ad60e 100644 --- a/developer/release.ps1 +++ b/developer/release.ps1 @@ -300,6 +300,7 @@ try { Require-ZipEntry $zipEntries 'index.html' Require-ZipEntry $zipEntries 'css/main.css' Require-ZipEntry $zipEntries 'css/heroui-bridge.css' +Require-ZipEntry $zipEntries 'css/theme-switcher-scroll.css' Require-ZipEntry $zipEntries 'css/onboarding.css' Require-ZipEntry $zipEntries 'assets/vendor/three.min.js' Require-ZipEntry $zipEntries 'assets/generated/reading-exams/manifest.js' @@ -311,13 +312,13 @@ Require-ZipEntry $zipEntries 'js/bundles/legacy-app.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/browse.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/practice.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/session.bundle.js' -Require-ZipEntry $zipEntries 'js/bundles/settings.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/diagnostics.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/more.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/theme.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/reading-page.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/practice-page-enhancer.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/listening-record-bridge.bundle.js' +Require-ZipEntry $zipEntries 'js/bundles/listening-wrapper.bundle.js' if ($IncludeLocalListening -and (Test-Path -LiteralPath (Join-Path $ProjectRoot 'assets/generated/listening-exams/manifest.js'))) { Require-ZipEntry $zipEntries 'assets/generated/listening-exams/manifest.js' diff --git a/developer/release.sh b/developer/release.sh index 672b7bfc..0c53fd58 100644 --- a/developer/release.sh +++ b/developer/release.sh @@ -156,6 +156,7 @@ reject_entry_pattern() { require_entry "index.html" require_entry "css/main.css" require_entry "css/heroui-bridge.css" +require_entry "css/theme-switcher-scroll.css" require_entry "css/onboarding.css" require_entry "assets/vendor/three.min.js" require_entry "assets/generated/reading-exams/manifest.js" @@ -167,13 +168,13 @@ require_entry "js/bundles/legacy-app.bundle.js" require_entry "js/bundles/browse.bundle.js" require_entry "js/bundles/practice.bundle.js" require_entry "js/bundles/session.bundle.js" -require_entry "js/bundles/settings.bundle.js" require_entry "js/bundles/diagnostics.bundle.js" require_entry "js/bundles/more.bundle.js" require_entry "js/bundles/theme.bundle.js" require_entry "js/bundles/reading-page.bundle.js" require_entry "js/bundles/practice-page-enhancer.bundle.js" require_entry "js/bundles/listening-record-bridge.bundle.js" +require_entry "js/bundles/listening-wrapper.bundle.js" if [ "${INCLUDE_LOCAL_LISTENING:-0}" = "1" ] && [ -f "assets/generated/listening-exams/manifest.js" ]; then require_entry "assets/generated/listening-exams/manifest.js" diff --git a/scripts/build-bundles.mjs b/scripts/build-bundles.mjs index f4b897a2..a9923c92 100644 --- a/scripts/build-bundles.mjs +++ b/scripts/build-bundles.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const root = path.resolve(__dirname, '..'); +const checkOnly = process.argv.includes('--check'); const bundles = { 'js/bundles/runtime-entry.bundle.js': [ @@ -17,25 +18,15 @@ const bundles = { 'js/bundles/core-foundation.bundle.js': [ 'js/utils/environmentDetector.js', 'js/utils/logger.js', - 'js/utils/storage.js', - 'js/core/storageProviderRegistry.js', - 'js/data/dataSources/storageDataSource.js', - 'js/data/repositories/baseRepository.js', - 'js/data/repositories/dataRepositoryRegistry.js', - 'js/data/repositories/practiceRepository.js', - 'js/data/repositories/settingsRepository.js', - 'js/data/repositories/backupRepository.js', - 'js/data/repositories/metaRepository.js', - 'js/data/index.js', - 'js/core/practiceCore.js', - 'js/core/practiceRecordAPI.js', - 'js/core/backupAPI.js', + 'js/data/practiceRecordSource.js', + 'js/data/v2/dataCatalog.js', + 'js/data/v2/dataKernel.js', + 'js/data/v2/appData.js', 'js/core/externalBackupService.js', - 'js/core/practiceStore.js', + 'js/core/siteDataReset.js', + 'js/core/practiceCore.js', 'js/core/resourceCore.js', 'assets/generated/reading-exams/manifest.js', - 'js/utils/stateSerializer.js', - 'js/utils/simpleStorageWrapper.js', 'js/app/state-service.js', 'js/services/libraryDiscovery.js', 'js/services/libraryManager.js' @@ -55,12 +46,12 @@ const bundles = { ], 'js/bundles/legacy-app.bundle.js': [ 'js/boot-fallbacks.js', - 'js/patches/runtime-fixes.js', 'js/app.js', 'js/components/onboardingTour.js' ], 'js/bundles/browse.bundle.js': [ 'js/views/legacyViewBundle.js', + 'js/data/practiceRecordSource.js', 'js/app/examActions.js', 'js/app/spellingErrorCollector.js', 'js/app/examSessionMixin.js', @@ -79,16 +70,11 @@ const bundles = { 'js/utils/dataConsistencyManager.js', 'js/utils/performance.js' ], - 'js/bundles/settings.bundle.js': [ - 'js/components/DataIntegrityManager.js', - 'js/utils/dataBackupManager.js' - ], 'js/bundles/practice.bundle.js': [ 'js/app/spellingErrorCollector.js', 'js/utils/markdownExporter.js', 'js/components/practiceRecordModal.js', 'js/components/practiceHistoryEnhancer.js', - 'js/core/scoreStorage.js', 'js/utils/answerSanitizer.js', 'js/core/practiceRecorder.js' ], @@ -96,6 +82,10 @@ const bundles = { 'js/app/suitePracticeMixin.js' ], 'js/bundles/reading-page.bundle.js': [ + 'js/data/practiceRecordSource.js', + 'js/data/v2/dataCatalog.js', + 'js/data/v2/dataKernel.js', + 'js/data/v2/appData.js', 'js/runtime/readingExamRegistry.js', 'js/runtime/readingExplanationRegistry.js', 'js/runtime/readingHighlightShared.js', @@ -109,17 +99,30 @@ const bundles = { 'js/runtime/unifiedReadingPage.js' ], 'js/bundles/practice-page-enhancer.bundle.js': [ + 'js/data/practiceRecordSource.js', + 'js/data/v2/dataCatalog.js', + 'js/data/v2/dataKernel.js', + 'js/data/v2/appData.js', 'js/utils/suiteBackGuard.js', 'js/utils/answerMatchCore.js', 'js/app/spellingErrorCollector.js', 'js/practice-page-enhancer.js' ], 'js/bundles/listening-record-bridge.bundle.js': [ - 'js/utils/answerMatchCore.js', - 'js/app/spellingErrorCollector.js', - 'js/listeningRecordBridge.js' - ], + 'js/data/practiceRecordSource.js', + 'js/data/v2/dataCatalog.js', + 'js/data/v2/dataKernel.js', + 'js/data/v2/appData.js', + 'js/utils/answerMatchCore.js', + 'js/app/spellingErrorCollector.js', + 'js/utils/safeObjectLiteralParser.js', + 'js/listeningRecordBridge.js' + ], 'js/bundles/listening-wrapper.bundle.js': [ + 'js/data/practiceRecordSource.js', + 'js/data/v2/dataCatalog.js', + 'js/data/v2/dataKernel.js', + 'js/data/v2/appData.js', 'js/utils/practiceTimerPreferences.js', 'js/listeningUnifiedWrapper.js' ], @@ -153,6 +156,323 @@ function readSource(relativePath) { .replace(/\s*$/, '\n'); } +// ============================================================================ +// 全局符号冲突检查 +// +// bundle 是多个源文件的纯文本拼接,没有模块作用域隔离:两个文件写入同一个全局名字 +// 时,后者会静默覆盖前者。历史事故:js/app/examActions.js 内 IIFE 导出的 +// loadExamList 覆盖了 js/main.js 的同名顶层函数(两者语义不同),导致用户切换 +// 筛选/排序后题库渲染成空白。 +// +// 真正的冲突面是"对全局命名空间的写入",共两条路径: +// 1. `global.X = ...` / `window.X = ...` / `Object.defineProperty(global|window, 'X', ...)` +// —— 允许任意缩进,因为这类写入通常发生在 IIFE 内部。 +// 2. 非 IIFE 包裹的"裸文件"(如 js/main.js)中缩进为 0 的顶层声明: +// `function X` / `async function X` / `var|let|const X` / `class X` +// —— 裸文件的顶层声明会直接落进 bundle 的顶层作用域,等价于全局写入。 +// IIFE 包裹的文件里这类声明是局部的,不算冲突(否则会漏掉上面那个真实 bug, +// 同时把大量私有函数误报成冲突)。 +// +// 只做正则词法分析,不引入任何解析器依赖;先屏蔽注释/字符串/模板/正则字面量, +// 以规避把这些内容里的文本误判成代码。不追求 100% 精确。 +// ============================================================================ + +/** + * 存量符号冲突白名单 —— 历史技术债务,待逐项清理。 + * + * - 白名单内的冲突:打印警告,不阻断构建。 + * - 白名单外的新增冲突:打印错误并以退出码 1 失败。 + * + * 每行一项且互不影响:清理掉某处冲突后,把对应的那一行删掉即可。 + * 判定为"已知"要求实际冲突文件是这里所列文件的子集;若有新文件加入同名符号, + * 说明冲突范围扩大了,会按新增冲突报错。 + */ +const KNOWN_SYMBOL_CONFLICTS = { + 'js/bundles/browse.bundle.js': { + __browseFilterMode: ['js/app/examActions.js', 'js/app/browseController.js', 'js/main.js'], + __browsePath: ['js/app/examActions.js', 'js/app/browseController.js', 'js/main.js'], + __readingMemorizeBrowseMode: ['js/app/examActions.js', 'js/main.js'], + __browseMemorizeFilterMode: ['js/app/examActions.js', 'js/main.js'], + clearPendingBrowseAutoScroll: ['js/utils/BrowsePreferencesUtils.js', 'js/main.js'], + pdfHandler: ['js/components/PDFHandler.js', 'js/main.js'], + browseStateManager: ['js/components/BrowseStateManager.js', 'js/main.js'] + }, + 'js/bundles/practice-page-enhancer.bundle.js': { + spellingErrorCollector: ['js/app/spellingErrorCollector.js', 'js/practice-page-enhancer.js'] + } +}; + +// `/` 出现在这些关键字之后只能是正则字面量,不可能是除法。 +const REGEX_ALLOWED_AFTER_KEYWORDS = new Set([ + 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', + 'void', 'throw', 'case', 'do', 'else', 'yield', 'await' +]); + +/** + * 把注释、字符串、模板字面量文本和正则字面量的内容替换成空格(保留换行与长度), + * 这样后续正则就不会把它们里面的文本误判成真实代码。 + */ +function maskSource(source) { + const out = source.split(''); + const length = source.length; + const blank = (from, to) => { + for (let index = from; index < to && index < length; index += 1) { + if (out[index] !== '\n') out[index] = ' '; + } + }; + + // 模板插值 `${...}` 内可能出现对象字面量的 `{}`,需要按大括号深度判断插值结束位置。 + const templateBraceDepth = []; + const readTemplateChunk = (from) => { + let index = from; + while (index < length) { + if (source[index] === '\\') { index += 2; continue; } + if (source[index] === '`') return { end: index, interpolated: false }; + if (source[index] === '$' && source[index + 1] === '{') return { end: index, interpolated: true }; + index += 1; + } + return { end: length, interpolated: false }; + }; + + let cursor = 0; + let previousChar = ''; + let previousWord = ''; + const regexAllowedHere = () => { + if (!previousChar) return true; + if (/[\w$]/.test(previousChar)) return REGEX_ALLOWED_AFTER_KEYWORDS.has(previousWord); + return previousChar !== ')' && previousChar !== ']'; + }; + + while (cursor < length) { + const char = source[cursor]; + const nextChar = source[cursor + 1]; + + if (char === '/' && nextChar === '/') { + let index = cursor; + while (index < length && source[index] !== '\n') index += 1; + blank(cursor, index); + cursor = index; + continue; + } + + if (char === '/' && nextChar === '*') { + let index = cursor + 2; + while (index < length && !(source[index] === '*' && source[index + 1] === '/')) index += 1; + index = Math.min(index + 2, length); + blank(cursor, index); + cursor = index; + continue; + } + + if (char === '"' || char === "'") { + let index = cursor + 1; + while (index < length) { + if (source[index] === '\\') { index += 2; continue; } + if (source[index] === char || source[index] === '\n') break; + index += 1; + } + blank(cursor + 1, index); + cursor = Math.min(index + 1, length); + previousChar = char; + previousWord = ''; + continue; + } + + if (char === '`') { + const chunk = readTemplateChunk(cursor + 1); + blank(cursor + 1, chunk.end); + if (chunk.interpolated) { + templateBraceDepth.push(0); + cursor = chunk.end + 2; + previousChar = '{'; + previousWord = ''; + continue; + } + cursor = Math.min(chunk.end + 1, length); + previousChar = '`'; + previousWord = ''; + continue; + } + + if (templateBraceDepth.length && char === '{') { + templateBraceDepth[templateBraceDepth.length - 1] += 1; + } else if (templateBraceDepth.length && char === '}') { + if (templateBraceDepth[templateBraceDepth.length - 1] > 0) { + templateBraceDepth[templateBraceDepth.length - 1] -= 1; + } else { + // 插值结束,回到模板文本继续屏蔽。 + templateBraceDepth.pop(); + const chunk = readTemplateChunk(cursor + 1); + blank(cursor + 1, chunk.end); + if (chunk.interpolated) { + templateBraceDepth.push(0); + cursor = chunk.end + 2; + previousChar = '{'; + previousWord = ''; + continue; + } + cursor = Math.min(chunk.end + 1, length); + previousChar = '`'; + previousWord = ''; + continue; + } + } + + if (char === '/' && regexAllowedHere()) { + let index = cursor + 1; + let inCharacterClass = false; + let closed = false; + while (index < length) { + const current = source[index]; + if (current === '\\') { index += 2; continue; } + if (current === '\n') break; + if (current === '[') inCharacterClass = true; + else if (current === ']') inCharacterClass = false; + else if (current === '/' && !inCharacterClass) { closed = true; break; } + index += 1; + } + if (closed) { + blank(cursor + 1, index); + cursor = index + 1; + previousChar = '/'; + previousWord = ''; + continue; + } + } + + if (/[\w$]/.test(char)) { + let index = cursor; + while (index < length && /[\w$]/.test(source[index])) index += 1; + previousWord = source.slice(cursor, index); + previousChar = source[index - 1]; + cursor = index; + continue; + } + + if (!/\s/.test(char)) { + previousChar = char; + previousWord = ''; + } + cursor += 1; + } + + return out.join(''); +} + +// 路径 1:对 global/window 属性的直接赋值,允许任意缩进(多在 IIFE 内部)。 +const GLOBAL_PROPERTY_WRITE = /(?:^|[^\w$.])(?:global|window)\s*\.\s*([A-Za-z_$][\w$]*)\s*=(?!=)/g; +// 路径 1 的补充形式:Object.defineProperty(global|window, 'X', ...) 同样是全局写入。 +const GLOBAL_DEFINE_PROPERTY = /Object\s*\.\s*defineProperty\s*\(\s*(?:global|window)\s*,\s*['"]([A-Za-z_$][\w$]*)['"]/g; +// 路径 2:裸文件里缩进为 0 的顶层声明(行首即声明关键字)。 +const TOP_LEVEL_DECLARATION = /^(?:async[ \t]+)?(?:function\b[ \t*]*|var[ \t]+|let[ \t]+|const[ \t]+|class[ \t]+)([A-Za-z_$][\w$]*)/; + +/** 判断整个文件是否被 IIFE 包裹(首个有效 token 就进入 `(function` / `((` 形态)。 */ +function isIifeWrapped(maskedSource) { + const head = maskedSource.replace(/\s+/g, ' ').trim(); + return /^[!+~;]*\s*\(\s*(?:async\s+)?function\b/.test(head) || /^[!+~;]*\s*\(\s*\(/.test(head); +} + +const globalSymbolCache = new Map(); + +/** 提取单个源文件写入的全局符号名集合(同一文件在多个 bundle 中复用,带缓存)。 */ +function collectGlobalSymbols(relativePath) { + if (globalSymbolCache.has(relativePath)) return globalSymbolCache.get(relativePath); + + const masked = maskSource(readSource(relativePath)); + const symbols = new Set(); + + let match; + GLOBAL_PROPERTY_WRITE.lastIndex = 0; + while ((match = GLOBAL_PROPERTY_WRITE.exec(masked)) !== null) { + symbols.add(match[1]); + // 前缀里可能吃掉了下一处匹配的起始字符,回退一位避免漏检相邻写入。 + GLOBAL_PROPERTY_WRITE.lastIndex = match.index + match[0].length - 1; + } + + GLOBAL_DEFINE_PROPERTY.lastIndex = 0; + while ((match = GLOBAL_DEFINE_PROPERTY.exec(masked)) !== null) { + symbols.add(match[1]); + } + + if (!isIifeWrapped(masked)) { + for (const line of masked.split('\n')) { + const declaration = TOP_LEVEL_DECLARATION.exec(line); + if (declaration) symbols.add(declaration[1]); + } + } + + globalSymbolCache.set(relativePath, symbols); + return symbols; +} + +/** 扫描所有 bundle,区分出"存量已知冲突"和"新增冲突"。 */ +function findSymbolConflicts(bundleMap) { + const known = []; + const introduced = []; + const staleWhitelistEntries = []; + + for (const [outputPath, inputs] of Object.entries(bundleMap)) { + const writers = new Map(); + for (const inputPath of inputs) { + for (const symbol of collectGlobalSymbols(inputPath)) { + if (!writers.has(symbol)) writers.set(symbol, []); + const owners = writers.get(symbol); + if (!owners.includes(inputPath)) owners.push(inputPath); + } + } + + const whitelist = KNOWN_SYMBOL_CONFLICTS[outputPath] || {}; + const conflictingSymbols = new Set(); + + for (const [symbol, owners] of writers) { + if (owners.length < 2) continue; + conflictingSymbols.add(symbol); + const allowedOwners = whitelist[symbol]; + const isKnown = Array.isArray(allowedOwners) + && owners.every((owner) => allowedOwners.includes(owner)); + (isKnown ? known : introduced).push({ outputPath, symbol, owners }); + } + + for (const symbol of Object.keys(whitelist)) { + if (!conflictingSymbols.has(symbol)) staleWhitelistEntries.push({ outputPath, symbol }); + } + } + + return { known, introduced, staleWhitelistEntries }; +} + +function formatConflict({ outputPath, symbol, owners }) { + return [ + `符号冲突: ${outputPath}`, + ` "${symbol}" 同时写入于:`, + ...owners.map((owner) => ` - ${owner}`) + ].join('\n'); +} + +/** 构建与 --check 模式下都会执行;出现新增冲突时直接失败。 */ +function assertNoNewSymbolConflicts(bundleMap) { + const { known, introduced, staleWhitelistEntries } = findSymbolConflicts(bundleMap); + + if (known.length) { + console.warn(`存量符号冲突 ${known.length} 处(历史债务,暂不阻断构建):`); + for (const conflict of known) console.warn(formatConflict(conflict)); + } + + if (staleWhitelistEntries.length) { + console.warn('以下白名单条目已不再冲突,可从 KNOWN_SYMBOL_CONFLICTS 中删除:'); + for (const entry of staleWhitelistEntries) console.warn(` - ${entry.outputPath} :: ${entry.symbol}`); + } + + if (introduced.length) { + console.error(`检测到 ${introduced.length} 处新增符号冲突(不在白名单内):`); + for (const conflict of introduced) console.error(formatConflict(conflict)); + console.error('同一 bundle 内多个文件写入同名全局符号会静默互相覆盖,请改名或收敛到单一来源。'); + process.exit(1); + } + + if (!known.length) console.log('符号冲突检查通过: 未发现同一 bundle 内的重复全局写入。'); +} + function renderBundle(outputPath, inputs) { const sections = inputs.map((inputPath) => { const source = readSource(inputPath); @@ -176,9 +496,40 @@ function renderBundle(outputPath, inputs) { ].join('\n'); } +assertNoNewSymbolConflicts(bundles); + +const staleOutputs = []; for (const [outputPath, inputs] of Object.entries(bundles)) { const absoluteOutput = path.join(root, outputPath); + const expected = renderBundle(outputPath, inputs); + if (checkOnly) { + const actual = fs.existsSync(absoluteOutput) ? fs.readFileSync(absoluteOutput, 'utf8') : null; + if (actual !== expected) staleOutputs.push(outputPath); + continue; + } fs.mkdirSync(path.dirname(absoluteOutput), { recursive: true }); - fs.writeFileSync(absoluteOutput, renderBundle(outputPath, inputs), 'utf8'); + fs.writeFileSync(absoluteOutput, expected, 'utf8'); console.log(`${outputPath}: ${inputs.length} files`); } + +const expectedOutputs = new Set(Object.keys(bundles).map((outputPath) => outputPath.replace(/\\/g, '/'))); +const bundleDirectory = path.join(root, 'js', 'bundles'); +const orphanOutputs = fs.existsSync(bundleDirectory) + ? fs.readdirSync(bundleDirectory) + .filter((name) => name.endsWith('.bundle.js')) + .map((name) => `js/bundles/${name}`) + .filter((outputPath) => !expectedOutputs.has(outputPath)) + .sort() + : []; + +if (checkOnly) { + if (staleOutputs.length || orphanOutputs.length) { + if (staleOutputs.length) console.error(`Stale or missing bundles:\n${staleOutputs.map((item) => ` - ${item}`).join('\n')}`); + if (orphanOutputs.length) console.error(`Orphan bundles:\n${orphanOutputs.map((item) => ` - ${item}`).join('\n')}`); + process.exitCode = 1; + } else { + console.log(`Bundle check passed: ${expectedOutputs.size} outputs are current.`); + } +} else if (orphanOutputs.length) { + console.warn(`Orphan bundles are not part of the manifest:\n${orphanOutputs.map((item) => ` - ${item}`).join('\n')}`); +} From 60c7214215397b2bacde333d10b212cfb715f28d Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:35:25 +0800 Subject: [PATCH 14/18] build: refresh generated runtime bundles --- js/bundles/browse.bundle.js | 3885 ++-- js/bundles/core-foundation.bundle.js | 15697 +++++++---------- js/bundles/diagnostics.bundle.js | 26 +- js/bundles/legacy-app.bundle.js | 1048 +- js/bundles/listening-record-bridge.bundle.js | 4598 ++++- js/bundles/listening-wrapper.bundle.js | 4141 ++++- js/bundles/more.bundle.js | 1038 +- js/bundles/practice-page-enhancer.bundle.js | 4350 ++++- js/bundles/practice.bundle.js | 3067 +--- js/bundles/reading-page.bundle.js | 6092 ++++++- js/bundles/runtime-entry.bundle.js | 302 +- js/bundles/session.bundle.js | 427 +- js/bundles/settings.bundle.js | 1554 -- js/bundles/theme.bundle.js | 75 +- js/bundles/ui-shell.bundle.js | 251 +- 15 files changed, 30040 insertions(+), 16511 deletions(-) delete mode 100644 js/bundles/settings.bundle.js diff --git a/js/bundles/browse.bundle.js b/js/bundles/browse.bundle.js index e5129cff..48601863 100644 --- a/js/bundles/browse.bundle.js +++ b/js/bundles/browse.bundle.js @@ -233,12 +233,11 @@ if (!record) { return false; } - var exam = index.find(function (item) { - return item && (item.id === record.examId || item.title === record.title); - }); - var examType = exam ? normalizeTypeValue(exam.type) : ''; - if (examType) { - return examType === targetType; + var suiteEntries = ensureArray(record.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some(function (entry) { + return normalizeTypeValue(entry && entry.type) === targetType; + }); } var recordType = normalizeTypeValue( record.type || @@ -249,6 +248,13 @@ if (recordType) { return recordType === targetType; } + var exam = index.find(function (item) { + return item && (item.id === record.examId || item.title === record.title); + }); + var examType = exam ? normalizeTypeValue(exam.type) : ''; + if (examType) { + return examType === targetType; + } // 无法确定类型时保持展示,避免题库切换导致历史记录被过滤掉 return true; }); @@ -299,7 +305,9 @@ if (typeof value !== 'number' || isNaN(value)) { return '0.0%'; } - return value.toFixed(1) + '%'; + // Practice-record summary UI: keep a single decimal place so + // correct/total ratios do not dump long floating tails into the list. + return (Math.round(value * 10) / 10).toFixed(1) + '%'; } function formatMinutes(minutes) { @@ -881,6 +889,21 @@ return used; } + function addProjectedErrorCounts(counts, projectedCounts) { + if (!projectedCounts || typeof projectedCounts !== 'object') { + return false; + } + var used = false; + Object.keys(projectedCounts).forEach(function addProjected(type) { + var value = Math.max(0, Number(projectedCounts[type]) || 0); + if (value > 0) { + addRadarCount(counts, type, value); + used = true; + } + }); + return used; + } + function addDetailCounts(counts, record) { var questionTypeMap = buildReadingQuestionTypeMap(record); var sources = getDetailSources(record); @@ -907,7 +930,24 @@ function calculateReadingRadarData(records) { var counts = {}; - var recentReadingRecords = ensureArray(records) + var radarCandidates = []; + ensureArray(records).forEach(function expandSuiteRecord(record) { + var suiteEntries = ensureArray(record && record.suiteEntrySummaries); + if (suiteEntries.length) { + suiteEntries.forEach(function addSuiteEntry(entry) { + if (!entry) { + return; + } + radarCandidates.push(Object.assign({}, entry, { + metadata: Object.assign({}, entry.metadata || {}, { type: entry.type }), + date: entry.date || (record && record.date) + })); + }); + return; + } + radarCandidates.push(record); + }); + var recentReadingRecords = radarCandidates .filter(function filterReading(record) { var metadata = record && record.metadata ? record.metadata : {}; var realData = record && record.realData ? record.realData : {}; @@ -927,6 +967,9 @@ .slice(0, 10); recentReadingRecords.forEach(function collectRecord(record) { + if (addProjectedErrorCounts(counts, record && record.questionTypeErrorCounts)) { + return; + } var performanceMap = record && (record.questionTypePerformance || (record.realData && record.realData.questionTypePerformance)); if (addPerformanceCounts(counts, performanceMap)) { @@ -1550,31 +1593,24 @@ // 练习洞察卡片选中的组件(热力图 / 中高频余量 / 阅读雷达)持久化, // 刷新或重开页面后沿用用户上次的选中组件,而不是总回到默认的热力图。 - var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget'; var SUPPORTED_PRACTICE_WIDGETS = ['heatmap', 'priority', 'radar']; + var persistedPracticeWidget = null; + if (window.AppData && window.AppData.preferences) { + window.AppData.ready.then(function () { return window.AppData.preferences.getPracticeWidget(); }).then(function (value) { + persistedPracticeWidget = SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null; + }).catch(function () {}); + } function loadPersistedPracticeWidget() { - try { - if (typeof localStorage === 'undefined' || !localStorage) { - return null; - } - var value = localStorage.getItem(PRACTICE_WIDGET_PREFERENCE_KEY); - return SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null; - } catch (_) { - return null; - } + return persistedPracticeWidget; } function persistPracticeWidget(widget) { - try { - if (typeof localStorage === 'undefined' || !localStorage) { - return; - } - if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) { - localStorage.setItem(PRACTICE_WIDGET_PREFERENCE_KEY, widget); - } - } catch (_) { - /* 持久化失败不影响渲染 */ + if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) { + persistedPracticeWidget = widget; + window.AppData.preferences.setPracticeWidget(widget).catch(function (error) { + console.warn('[PracticeWidget] 保存失败:', error); + }); } } @@ -2265,7 +2301,10 @@ var durationInSeconds = Number(record && record.duration) || 0; var percentage = typeof record.percentage === 'number' ? record.percentage - : Math.round((record.accuracy || 0) * 100); + : ((Number(record.accuracy) || 0) * 100); + if (!Number.isFinite(percentage)) { + percentage = 0; + } var recordId = ''; if (record && record.id != null) { @@ -2337,7 +2376,7 @@ createNode('div', { className: 'record-percentage', style: { color: helpers.getScoreColor(percentage) } - }, percentage + '%') + }, formatPercentage(percentage)) ]); var actions = null; @@ -3210,10 +3249,125 @@ }; } + var browseCompletionIndex = { + byExamId: new Map(), + byTitle: new Map(), + records: [], + ready: false + }; + + function rememberCompletionCandidate(map, key, candidate) { + if (!map || !key || !candidate) { + return; + } + var existing = map.get(key); + if (!existing || candidate.timestamp > existing.timestamp) { + map.set(key, candidate); + } + } + + /** + * 在 setPracticeRecords 时重建一次正确率索引。 + * 不使用 version 计数器;生命周期绑定“写状态那一次”。 + */ + function resolveRecordExamId(record) { + if (!record || typeof record !== 'object') { + return ''; + } + var metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {}; + var realData = record.realData && typeof record.realData === 'object' ? record.realData : {}; + var rawData = record.rawData && typeof record.rawData === 'object' ? record.rawData : {}; + return record.examId || metadata.examId || realData.examId || rawData.examId || ''; + } + + function getBrowseSuiteEntries(record) { + if (!record || typeof record !== 'object') { + return []; + } + var summaries = Array.isArray(record.suiteEntrySummaries) ? record.suiteEntrySummaries : []; + if (summaries.length) { + return summaries; + } + return Array.isArray(record.suiteEntries) ? record.suiteEntries : []; + } + + function rebuildBrowseCompletionIndex(records) { + var byExamId = new Map(); + var byTitle = new Map(); + var recordSnapshot = ensureArray(records).slice(); + recordSnapshot.forEach(function indexRecord(record) { + if (!record || typeof record !== 'object') { + return; + } + var candidate = buildCompletionStatusCandidate(record); + var recordExamId = resolveRecordExamId(record); + if (recordExamId) { + rememberCompletionCandidate(byExamId, String(recordExamId), candidate); + } + var recordTitle = record.title || record.examTitle || (record.metadata && record.metadata.examTitle) || ''; + if (recordTitle) { + rememberCompletionCandidate(byTitle, String(recordTitle), candidate); + } + var suiteEntries = getBrowseSuiteEntries(record); + suiteEntries.forEach(function indexSuiteEntry(entry) { + if (!entry || typeof entry !== 'object') { + return; + } + var comparableEntry = buildComparableSuiteEntryRecord(record, entry); + var entryCandidate = buildCompletionStatusCandidate(comparableEntry, record); + var entryExamId = resolveRecordExamId(comparableEntry); + if (entryExamId) { + rememberCompletionCandidate(byExamId, String(entryExamId), entryCandidate); + } + var entryTitle = comparableEntry.title || comparableEntry.examTitle || ''; + if (entryTitle) { + rememberCompletionCandidate(byTitle, String(entryTitle), entryCandidate); + } + }); + }); + browseCompletionIndex = { + byExamId: byExamId, + byTitle: byTitle, + records: recordSnapshot, + ready: true + }; + return browseCompletionIndex; + } + + function ensureBrowseCompletionIndex() { + if (browseCompletionIndex.ready) { + return browseCompletionIndex; + } + return browseCompletionIndex; + } + LegacyExamListView.prototype._getCompletionStatus = function _getCompletionStatus(exam) { - var source = (typeof global.getPracticeRecordsState === 'function') - ? global.getPracticeRecordsState() - : global.practiceRecords; + var index = ensureBrowseCompletionIndex(); + var byId = null; + var byTitle = null; + if (exam && exam.id && index.byExamId.has(String(exam.id))) { + byId = index.byExamId.get(String(exam.id)); + } + if (exam && exam.title && index.byTitle.has(String(exam.title))) { + byTitle = index.byTitle.get(String(exam.title)); + } + // 同时有 examId / title 命中时取较新时间戳,避免旧 examId 遮蔽更新 title 匹配。 + var indexed = null; + if (byId && byTitle) { + indexed = (Number(byId.timestamp) || 0) >= (Number(byTitle.timestamp) || 0) ? byId : byTitle; + } else { + indexed = byId || byTitle; + } + if (indexed) { + return { + percentage: typeof indexed.percentage === 'number' ? indexed.percentage : 0, + date: indexed.date || null, + duration: typeof indexed.duration === 'number' ? indexed.duration : 0 + }; + } + + // Path/file fallback scans the same authoritative snapshot used to build the index. + var source = index.records; var statuses = []; ensureArray(source).forEach(function collectStatus(record) { if (!record || typeof record !== 'object') { @@ -3222,7 +3376,7 @@ if (recordMatchesExam(exam, record)) { statuses.push(buildCompletionStatusCandidate(record)); } - var suiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : []; + var suiteEntries = getBrowseSuiteEntries(record); suiteEntries.forEach(function collectSuiteEntry(entry) { if (!entry || typeof entry !== 'object') { return; @@ -3247,6 +3401,8 @@ }; }; + global.rebuildBrowseCompletionIndex = rebuildBrowseCompletionIndex; + // --- Legacy navigation controller --- function LegacyNavigationController(options) { options = options || {}; @@ -3525,8 +3681,8 @@ }; LibraryConfigView.prototype._renderItem = function _renderItem(config, activeKey, allowDelete) { - var isActive = activeKey === config.key; - var isDefault = config.key === 'exam_index'; + var isDefault = config.builtIn === true; + var isActive = isDefault ? activeKey == null : activeKey === config.key; var className = this.classNames.item + (isActive ? ' ' + this.classNames.itemActive : ''); var item = this._createElement('div', { @@ -3554,7 +3710,7 @@ type: 'button', dataset: { configAction: 'switch', - configKey: config.key, + configKey: config.key || '', configActive: isActive ? '1' : '0' } }, '切换'); @@ -3581,7 +3737,7 @@ type: 'button', dataset: { configAction: 'delete', - configKey: config.key, + configKey: config.key || '', configActive: isActive ? '1' : '0' } }, '删除'); @@ -3787,6 +3943,208 @@ })(window); +/* ===== js/data/practiceRecordSource.js ===== */ +/** + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 + */ +(function initPracticeRecordSource(global) { + 'use strict'; + + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; + } + + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); + + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); + + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); + + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); + + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); + } + + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); + } + + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; + } + + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); + } + + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; + + if (demoSourceSet.has(readMetadataSource(record))) return false; + + return true; + } + + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } + + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } + + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); + + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } + + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; + } + + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; + } + return previewRecordIds.delete(normalizeId(recordId)); + } + + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); + } + + /** + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 + */ + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); + + global.PracticeRecordSource = api; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); + + /* ===== js/app/examActions.js ===== */ (function (global) { 'use strict'; @@ -4142,15 +4500,9 @@ return categories[Math.max(0, stageIndex)] || null; } - function findExamById(examId) { - const list = Array.isArray(global.examIndex) - ? global.examIndex - : (global.appStateService && typeof global.appStateService.getExamIndex === 'function' - ? global.appStateService.getExamIndex() - : []); - return Array.isArray(list) - ? list.find((item) => item && String(item.id) === String(examId)) - : null; + function findExamById(examId, examIndex) { + const list = Array.isArray(examIndex) ? examIndex : []; + return list.find((item) => item && String(item.id) === String(examId)) || null; } function isReadingMemorizeBrowseMode() { @@ -4199,8 +4551,11 @@ return (Array.isArray(exams) ? exams : []).filter(isReadingMemorizeExam); } - function launchReadingMemorizeExam(examId) { - const exam = findExamById(examId); + async function launchReadingMemorizeExam(examId, examIndex = null) { + const list = Array.isArray(examIndex) + ? examIndex + : await global.resolveActiveLibraryIndex(); + const exam = findExamById(examId, list); if (!isReadingMemorizeExam(exam)) { if (typeof global.showMessage === 'function') { global.showMessage('该题目无法使用统一阅读页背题,请选择有 HTML 数据的阅读题。', 'warning'); @@ -4491,13 +4846,16 @@ } } - function handleCustomSuiteSelect(examId) { + async function handleCustomSuiteSelect(examId, examIndex = null) { const draft = getCustomSuiteDraft(); if (!draft || draft.status === 'ready') { return false; } - const exam = findExamById(examId); + const list = Array.isArray(examIndex) + ? examIndex + : await global.resolveActiveLibraryIndex(); + const exam = findExamById(examId, list); if (!exam) { return false; } @@ -4598,7 +4956,7 @@ /** * 加载并渲染题库列表 */ - function loadExamList() { + function loadExamList(examIndex = []) { console.log('[ExamActions] loadExamList called'); if (typeof global.setupBrowseControls === 'function') { @@ -4618,13 +4976,13 @@ if (!memorizeSelectionActive && global.__browseFilterMode && global.__browseFilterMode !== 'default' && global.browseController) { try { if (!global.browseController.buttonContainer) { - global.browseController.initialize('type-filter-buttons'); + global.browseController.initialize('type-filter-buttons', examIndex); } if (global.browseController.currentMode !== global.__browseFilterMode) { - global.browseController.setMode(global.__browseFilterMode); + global.browseController.setMode(global.__browseFilterMode, examIndex); } else { const activeFilter = global.browseController.activeFilter || 'all'; - global.browseController.applyFilter(activeFilter); + global.browseController.applyFilter(activeFilter, examIndex); } return; } catch (error) { @@ -4632,15 +4990,8 @@ } } - // 2. 获取题库快照 - let examIndexSnapshot = []; - if (global.appStateService) { - examIndexSnapshot = global.appStateService.getExamIndex(); - } else if (typeof global.getExamIndexState === 'function') { - examIndexSnapshot = global.getExamIndexState(); - } else { - examIndexSnapshot = Array.isArray(global.examIndex) ? global.examIndex : []; - } + // 2. 使用控制器边界传入的本次题库快照。 + const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : []; // 3. 获取筛选条件 let activeCategory = 'all'; @@ -5110,46 +5461,12 @@ return Promise.resolve(); } - function ensureSettingsToolsReady() { - if (global.AppLazyLoader && typeof global.AppLazyLoader.ensureGroup === 'function') { - return global.AppLazyLoader.ensureGroup('settings-tools'); - } - return ensureBrowseGroupReady(); - } - - async function ensureDataIntegrityManagerReady() { - try { - await ensureSettingsToolsReady(); - } catch (error) { - console.warn('[ExamActions] 设置工具预加载失败,继续尝试导出:', error); - } - - if (!global.dataIntegrityManager && global.DataIntegrityManager) { - try { - global.dataIntegrityManager = new global.DataIntegrityManager(); - } catch (error) { - console.warn('[ExamActions] 初始化 DataIntegrityManager 失败:', error); - } - } - - return global.dataIntegrityManager || null; - } - async function exportPracticeData() { try { - if (global.dataIntegrityManager && typeof global.dataIntegrityManager.exportData === 'function') { - global.dataIntegrityManager.exportData(); - try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { } - return; - } - } catch (_) { } - try { - var records = global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function' - ? await global.PracticeRecordAPI.list() - : (global.getPracticeRecordsState ? global.getPracticeRecordsState() : []); - var blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json; charset=utf-8' }); + var snapshot = await global.AppData.backups.export({ domains: ['practice'] }); + var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' }); var url = URL.createObjectURL(blob); - var a = document.createElement('a'); a.href = url; a.download = 'practice-records.json'; + var a = document.createElement('a'); a.href = url; a.download = 'ielts-atlas-practice-v2.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); try { global.showMessage && global.showMessage('导出完成', 'success'); } catch (_) { } @@ -5160,14 +5477,17 @@ } async function exportAllData() { - var manager = null; try { - manager = await ensureDataIntegrityManagerReady(); - if (manager && typeof manager.exportData === 'function') { - await manager.exportData(); - try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { } - return; - } + var snapshot = await global.AppData.backups.export(); + var blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json; charset=utf-8' }); + var url = URL.createObjectURL(blob); + var anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'; + document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); + try { await global.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (historyError) { console.warn('[ExamActions] 导出历史记录失败:', historyError); } + try { global.showMessage && global.showMessage('数据导出成功', 'success'); } catch (_) { } + return snapshot; } catch (error) { console.error('[ExamActions] 数据导出失败:', error); if (typeof global.showMessage === 'function') { @@ -5176,9 +5496,7 @@ return; } - if (typeof global.exportPracticeData === 'function') { - return global.exportPracticeData(); - } + return null; if (typeof global.showMessage === 'function') { global.showMessage('Data manager module is unavailable.', 'warning'); } @@ -5239,7 +5557,8 @@ isReadingMemorizeExam }; - global.loadExamList = loadExamList; + // 全局 loadExamList 由 main.js 的适配器持有(无参时自解析题库索引); + // 此处仅通过 global.ExamActions.loadExamList 暴露,避免覆盖后无参调用拿到空数组。 global.resetBrowseViewToAll = resetBrowseViewToAll; global.displayExams = displayExams; global.setupExamActionHandlers = setupExamActionHandlers; @@ -5316,12 +5635,11 @@ // 错误缓存,用于临时存储检测到的错误 this.errorCache = new Map(); - // 词表存储键配置 - this.storageKeys = { - p1: 'vocab_list_p1_errors', - p4: 'vocab_list_p4_errors', - master: 'vocab_list_master_errors', - custom: 'vocab_list_custom' + this.collectionIds = { + p1: 'spelling-errors-p1', + p4: 'spelling-errors-p4', + master: 'spelling-errors-master', + custom: 'custom' }; this.lexiconCache = null; @@ -5339,17 +5657,8 @@ */ async init() { try { - // 等待存储系统就绪 - if (window.storage && window.storage.ready) { - await window.storage.ready; - } - - // 设置命名空间 - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[SpellingErrorCollector] 存储命名空间已设置'); - } - + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; this.initialized = true; console.log('[SpellingErrorCollector] 初始化完成'); } catch (error) { @@ -5717,14 +6026,9 @@ try { await this.ensureInitialized(); - const storageKey = this.storageKeys[listId] || listId; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return null; - } - - const list = await window.storage.get(storageKey); + const collectionId = this.collectionIds[listId] || listId; + const collections = await window.AppData.vocab.listCollections(); + const list = collections[collectionId]; const normalizedList = this.normalizeVocabListShape(list, listId, listId); if (normalizedList) { @@ -5736,7 +6040,7 @@ return null; } catch (error) { console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error); - return null; + throw error; } } @@ -5748,31 +6052,10 @@ async saveVocabList(vocabList) { try { await this.ensureInitialized(); - - if (!vocabList || !vocabList.id) { - console.error('[SpellingErrorCollector] 无效的词表对象'); - return false; - } - - if (!Array.isArray(vocabList.words)) { - vocabList.words = []; - } - - vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; - - // 更新统计信息 - vocabList.stats = vocabList.stats || {}; - vocabList.stats.totalWords = vocabList.words.length; - vocabList.updatedAt = Date.now(); - - const storageKey = this.storageKeys[vocabList.id] || vocabList.id; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return false; - } - - await window.storage.set(storageKey, vocabList); + vocabList = this.prepareVocabList(vocabList); + if (!vocabList) return false; + const collectionId = this.collectionIds[vocabList.id] || vocabList.id; + await window.AppData.vocab.saveCollection(collectionId, vocabList); console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`); return true; @@ -5782,6 +6065,19 @@ } } + prepareVocabList(vocabList) { + if (!vocabList || !vocabList.id) { + console.error('[SpellingErrorCollector] 无效的词表对象'); + return null; + } + if (!Array.isArray(vocabList.words)) vocabList.words = []; + const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; + normalized.stats = normalized.stats || {}; + normalized.stats.totalWords = normalized.words.length; + normalized.updatedAt = Date.now(); + return normalized; + } + /** * 获取词表单词数量 * @param {string} listId - 词表ID @@ -5793,7 +6089,7 @@ return list ? list.words.length : 0; } catch (error) { console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error); - return 0; + throw error; } } @@ -6363,17 +6659,25 @@ try { await this.ensureInitialized(); await this.ensureCoreLexicon(); - - // 按来源分组错误 const errorsBySource = this.groupErrorsBySource(errors); - - // 保存到各个来源的词表 + const pendingCollections = {}; for (const [source, sourceErrors] of Object.entries(errorsBySource)) { - await this.saveErrorsToList(source, sourceErrors); + let vocabList = await this.loadVocabList(source); + if (!vocabList) vocabList = this.createEmptyList(source, source); + this.mergeErrorsToList(vocabList, sourceErrors); + const prepared = this.prepareVocabList(vocabList); + if (!prepared) throw new Error(`生成 ${source} 错词词表失败`); + pendingCollections[this.collectionIds[source] || source] = prepared; } - // 同步到综合词表 - await this.syncToMasterList(errors); + let masterList = await this.loadVocabList('master'); + if (!masterList) masterList = this.createEmptyList('master', 'all'); + this.mergeErrorsToList(masterList, errors); + const preparedMaster = this.prepareVocabList(masterList); + if (!preparedMaster) throw new Error('生成综合错词词表失败'); + pendingCollections[this.collectionIds.master] = preparedMaster; + + await window.AppData.vocab.saveCollections(pendingCollections); console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`); return true; @@ -6524,7 +6828,9 @@ ); if (vocabList.words.length < originalLength) { - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`); return true; } else { @@ -6554,7 +6860,9 @@ vocabList.words = []; vocabList.updatedAt = Date.now(); - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 清空词表: ${listId}`); return true; @@ -6586,59 +6894,11 @@ const PRACTICE_ENHANCER_BUILD_ID = '20250105'; async function getActiveExamIndexSnapshot() { - const stateGetters = [ - () => (typeof global.getExamIndexState === 'function') ? global.getExamIndexState() : null, - () => (typeof getExamIndexState === 'function') ? getExamIndexState : null - ]; - - for (const getterFactory of stateGetters) { - try { - const getter = getterFactory(); - if (typeof getter === 'function') { - const state = getter(); - if (Array.isArray(state) && state.length) { - return state.slice(); - } - } - } catch (_) { } - } - - let activeKey = 'exam_index'; - try { - if (typeof global.getActiveLibraryConfigurationKey === 'function') { - const resolved = await global.getActiveLibraryConfigurationKey(); - if (resolved && typeof resolved === 'string' && resolved.trim()) { - activeKey = resolved.trim(); - } - } else { - const storedKey = await storage.get('active_exam_index_key', 'exam_index'); - if (storedKey && typeof storedKey === 'string' && storedKey.trim()) { - activeKey = storedKey.trim(); - } - } - } catch (_) { - try { - const storedKey = await storage.get('active_exam_index_key', 'exam_index'); - if (storedKey && typeof storedKey === 'string' && storedKey.trim()) { - activeKey = storedKey.trim(); - } - } catch (_) { } - } - - let dataset = await storage.get(activeKey, []) || []; - if ((!Array.isArray(dataset) || dataset.length === 0) && activeKey !== 'exam_index') { - dataset = await storage.get('exam_index', []) || []; - } - if (!Array.isArray(dataset) || dataset.length === 0) { - if (Array.isArray(global.examIndex) && global.examIndex.length) { - dataset = global.examIndex.slice(); - } else if (typeof global.getReadingExamIndex === 'function') { - dataset = global.getReadingExamIndex(); - } else if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) { - dataset = global.__READING_EXAM_INDEX__.slice(); - } + if (typeof global.resolveActiveLibraryIndex !== 'function') { + throw new Error('LibraryManager.resolveActiveIndex is unavailable'); } - return Array.isArray(dataset) ? dataset : []; + const dataset = await global.resolveActiveLibraryIndex(); + return Array.isArray(dataset) ? dataset.slice() : []; } async function findExamDefinition(examId) { @@ -6651,20 +6911,6 @@ return match; } - const fallbacks = [ - Array.isArray(global.examIndex) ? global.examIndex : null, - typeof global.getReadingExamIndex === 'function' ? global.getReadingExamIndex() : null, - Array.isArray(global.__READING_EXAM_INDEX__) ? global.__READING_EXAM_INDEX__ : null, - Array.isArray(global.listeningExamIndex) ? global.listeningExamIndex : null - ]; - for (const fallback of fallbacks) { - if (!Array.isArray(fallback)) continue; - const found = fallback.find(entry => entry && entry.id === examId); - if (found) { - return found; - } - } - return null; } @@ -6948,10 +7194,18 @@ * 打开指定题目进行练习 */ async openExam(examId, options = {}) { - const examIndex = await getActiveExamIndexSnapshot(); - const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []); - const exam = list.find(e => e.id === examId); const reviewMode = Boolean(options && options.reviewMode); + let exam = options && options.examDefinition && typeof options.examDefinition === 'object' + ? options.examDefinition + : null; + if (!exam) { + if (options && options.requireRecordProvenance) { + throw new Error('历史记录的题库来源不可用'); + } + const examIndex = await getActiveExamIndexSnapshot(); + const list = Array.isArray(examIndex) ? examIndex : []; + exam = list.find(e => e.id === examId); + } const practiceMode = options && typeof options.practiceMode === 'string' ? options.practiceMode.trim().toLowerCase() : ''; @@ -6994,6 +7248,9 @@ if (guardOptions.suiteSessionId && readingLaunch && readingLaunch.mode === 'unified_html') { examUrl = this._appendSuiteContextToExamUrl(examUrl, guardOptions); } + if (guardOptions.endlessMode) { + examUrl = this._appendEndlessContextToExamUrl(examUrl); + } let examWindow = this.openExamWindow(examUrl, exam, guardOptions); try { @@ -7008,12 +7265,27 @@ await this._cleanupReusedWindowSessions(examWindow, examId); } - // 再进行会话记录与脚本注入 + // 在启动窗口前捕获激活的题库配置 ID,确保后续练习记录 metadata 来源 + // 一律按"启动时"的题库写入,避免用户在考试过程中切换题库导致提交时来源不一致。 + if (!reviewMode) { + try { + await this._captureLaunchLibraryConfigurationId(examId); + } catch (captureError) { + console.warn('[App] 捕获启动题库配置 ID 失败:', captureError); + } + } + + // Register the window first so the host expectedSessionId exists, then start the + // recorder with that same id. Starting the recorder before window setup used + // to mint a second session id that never matched INIT/COMPLETE. + this.setupExamWindowManagement(examWindow, examId, exam, { + ...options, + expectedUrl: this._ensureAbsoluteUrl(examUrl) + }); if (!reviewMode && !memorizeMode) { await this.startPracticeSession(examId); } this.injectDataCollectionScript(examWindow, examId, exam); - this.setupExamWindowManagement(examWindow, examId, exam, options); if (options && options.suiteSessionId) { const sessionInfo = this.ensureExamWindowSession(examId, examWindow); @@ -7214,6 +7486,87 @@ } }, + _resolveExamMessageEndpoint(rawUrl) { + const href = this._ensureAbsoluteUrl(rawUrl); + if (!href) { + return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false }; + } + try { + const parsed = new URL(href, window.location.href); + // Chromium reports URL.origin as "file://" while postMessage events + // between file pages use the opaque origin "null". + if (parsed.protocol === 'file:') { + return { + expectedUrl: parsed.href, + expectedOrigin: 'null', + allowOpaqueOrigin: true + }; + } + if (parsed.origin && parsed.origin !== 'null') { + return { + expectedUrl: parsed.href, + expectedOrigin: parsed.origin, + allowOpaqueOrigin: false + }; + } + } catch (_) { + // An unparseable launch URL must never degrade to wildcard messaging. + } + return { expectedUrl: '', expectedOrigin: '', allowOpaqueOrigin: false }; + }, + + _reportExamMessageRejected(examId, type, reason, event = null) { + if (!this._examMessageRejectionCounts) this._examMessageRejectionCounts = new Map(); + const key = `${String(reason || 'unknown')}:${String(type || 'unknown')}`; + const count = Number(this._examMessageRejectionCounts.get(key) || 0) + 1; + this._examMessageRejectionCounts.set(key, count); + const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + const originClass = incomingOrigin === 'null' + ? 'opaque' + : (incomingOrigin && window.location && incomingOrigin === window.location.origin ? 'same-origin' : (incomingOrigin ? 'cross-origin' : 'missing')); + const detail = { + reason: String(reason || 'unknown'), + messageType: String(type || 'unknown'), + examId: String(examId || ''), + originClass, + count + }; + if (count === 1 || count % 10 === 0) { + console.debug('[ExamMessage] rejected', detail); + } + try { + window.dispatchEvent(new CustomEvent('ielts-atlas:message-rejected', { detail })); + } catch (_) { + // Telemetry must never affect the security decision. + } + return false; + }, + + _postExamMessage(examId, targetWindow, type, data = {}) { + if (!targetWindow || targetWindow.closed || typeof targetWindow.postMessage !== 'function') { + return false; + } + const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + const targetOrigin = windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null' + ? windowInfo.expectedOrigin + : (windowInfo.allowOpaqueOrigin ? '*' : ''); + if (!targetOrigin) { + console.warn('[App] 拒绝向未绑定可信 origin 的题目窗口发送消息:', type, examId); + return false; + } + const payload = Object.assign({}, data || {}, { + examId: data && data.examId != null ? data.examId : examId, + windowSessionToken: windowInfo.windowSessionToken + }); + targetWindow.postMessage({ + type, + data: payload, + source: 'exam_host', + timestamp: Date.now() + }, targetOrigin); + return true; + }, + _appendSuiteContextToExamUrl(rawUrl, options = {}) { if (!rawUrl) { return rawUrl; @@ -7251,6 +7604,19 @@ } }, + _appendEndlessContextToExamUrl(rawUrl) { + if (!rawUrl) { + return rawUrl; + } + try { + const parsed = new URL(rawUrl, (window && window.location && window.location.href) ? window.location.href : undefined); + parsed.searchParams.set('endless', '1'); + return parsed.toString(); + } catch (_) { + return rawUrl; + } + }, + _normalizeSuiteTimerAnchor(value) { if (value == null || value === '') { return null; @@ -7415,6 +7781,14 @@ if (!examWindow || examWindow.closed) { return examWindow; } + // Separate file:// documents have opaque origins. Reading a child + // window's location is forbidden even when both files are local, + // and the launch URL has already been resolved by openExam(). + if (typeof window !== 'undefined' + && window.location + && window.location.protocol === 'file:') { + return examWindow; + } const resolveHref = (targetWindow) => { try { @@ -7542,6 +7916,7 @@ _buildExamPlaceholderUrl(exam = null, options = {}) { const basePath = 'templates/exam-placeholder.html'; const params = new URLSearchParams(); + params.set('suite_test', '1'); const safeSet = (key, value) => { if (value == null) { @@ -7673,6 +8048,11 @@ return; } + if (isListeningExam && doc.documentElement + && doc.documentElement.dataset.listeningWrapper === 'true') { + return; + } + // 套题占位页自带消息协议与按钮,不需要再注入增强器(避免重复发送 PRACTICE_COMPLETE) try { if (doc.getElementById('complete-exam-btn') && doc.getElementById('force-ready-btn')) { @@ -7767,6 +8147,8 @@ } const sessionToken = `${examId}_${Date.now()}`; + // 备用方案注入时同步读取 host 端启动时捕获的题库配置 ID,确保 enhancer 也能拿到来源。 + const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId); const inlineScript = examWindow.document.createElement('script'); inlineScript.type = 'text/javascript'; inlineScript.textContent = ` @@ -7782,7 +8164,26 @@ examId: ${JSON.stringify(examId)}, startTime: Date.now(), answers: {}, - suite: { + // 启动时 host 端捕获的题库配置 ID;每条 INIT_SESSION 还会再次以 + // initData.libraryConfigurationId 同步更新,确保即使延迟加载也能拿到正确来源。 + libraryConfigurationId: ${JSON.stringify(launchLibraryConfigurationId || null)}, + expectedParentOrigin: (function() { + try { + if (!document.referrer) return ''; + var parsed = new URL(document.referrer, window.location.href); + // Chromium: file URL.origin is "file://", postMessage event.origin is "null". + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (_) { + return ''; + } + })(), + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + submissionId: '', + suite: { active: false, sessionId: null, guarded: false, @@ -7791,12 +8192,40 @@ } }; + function createSubmissionId() { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return 'inline-submit-' + window.crypto.randomUUID(); + } + } catch (_) {} + return 'inline-submit-' + (state.sessionId || state.examId || 'session') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2); + } + function sendMessage(type, data) { if (!parentWindow || typeof parentWindow.postMessage !== 'function') { return; } try { - parentWindow.postMessage({ type: type, data: data || {} }, '*'); + var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) return; + var payload = Object.assign({}, data || {}); + if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') { + if (!state.submissionId) { + state.submissionId = payload.submissionId || createSubmissionId(); + } + payload.sessionId = payload.sessionId || state.sessionId || null; + payload.submissionId = payload.submissionId || state.submissionId; + } + parentWindow.postMessage({ + type: type, + data: Object.assign(payload, { + windowSessionToken: state.windowSessionToken || null + }), + source: 'inline_collector', + timestamp: Date.now() + }, targetOrigin); } catch (error) { console.warn('[InlineEnhancer] 无法发送消息:', error); } @@ -7913,11 +8342,21 @@ function handleInitSession(message) { var initData = message && message.data ? message.data : {}; if (initData.sessionId) { + if (state.sessionId && String(state.sessionId) !== String(initData.sessionId)) { + state.submissionId = ''; + } state.sessionId = initData.sessionId; } if (initData.examId) { state.examId = initData.examId; } + // host 启动时捕获并随 INIT_SESSION 携带的题库配置 ID;这里同步更新 state, + // 在 enhancer 回传完成结果时一并透传,避免后续提交再读当前激活题库。 + if (typeof initData.libraryConfigurationId !== 'undefined' + && initData.libraryConfigurationId !== null + && initData.libraryConfigurationId !== '') { + state.libraryConfigurationId = initData.libraryConfigurationId; + } if (initData.suiteSessionId) { state.suite.active = true; state.suite.sessionId = initData.suiteSessionId; @@ -7939,10 +8378,55 @@ } if (message.type === 'INIT_SESSION') { + var initData = message.data || {}; + var incomingOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + var declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : ''; + var incomingToken = typeof initData.windowSessionToken === 'string' + ? initData.windowSessionToken.trim() + : ''; + if (!event || event.source !== parentWindow || message.source !== 'exam_host' || !incomingToken) return; + var expectedParentOrigin = state.expectedParentOrigin + && state.expectedParentOrigin !== 'file://' + && String(state.expectedParentOrigin).indexOf('file:') !== 0 + ? state.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return; + state.parentOrigin = expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else if (window.location.protocol === 'file:') { + var trustedFileOrigin = incomingOrigin === 'null' + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) return; + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } else { + var trustedWebOrigin = !!incomingOrigin + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) return; + state.parentOrigin = incomingOrigin; + state.parentOriginIsOpaque = false; + } + state.windowSessionToken = incomingToken; handleInitSession(message); return; } + var messageData = message.data || {}; + var messageToken = typeof messageData.windowSessionToken === 'string' + ? messageData.windowSessionToken.trim() + : ''; + var messageOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + var originMatches = state.parentOriginIsOpaque + ? messageOrigin === 'null' + : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin); + if (!event || event.source !== parentWindow || message.source !== 'exam_host' + || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) { + return; + } + if (!state.suite.active) { return; } @@ -8004,7 +8488,9 @@ examId: state.examId, duration: Math.round((Date.now() - state.startTime) / 1000), answers: state.answers, - source: 'inline_collector' + source: 'inline_collector', + // 透传启动时捕获的题库配置 ID,便于 host 端 completeAttempt 写入 metadata 来源。 + libraryConfigurationId: state.libraryConfigurationId || null }); } }; @@ -8082,10 +8568,7 @@ const initPayload = this._buildExamInitPayload(examId, windowInfo, { timestamp: now }); // 发送会话初始化消息 - examWindow.postMessage({ - type: 'INIT_SESSION', - data: initPayload - }, '*'); + this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); // 存储会话信息 if (!this.examWindows) { @@ -8133,13 +8616,7 @@ type: 'script_injection_error' }; - // 保存错误日志到本地存储 - const errorLogs = await storage.get('injection_errors', []); - errorLogs.push(errorInfo); - if (errorLogs.length > 50) { - errorLogs.splice(0, errorLogs.length - 50); // 保留最近50条错误 - } - await storage.set('injection_errors', errorLogs); + console.warn('[DataInjection] 诊断信息:', errorInfo); // 不显示错误给用户,静默处理 console.warn('[DataInjection] 将使用模拟数据模式'); @@ -8168,12 +8645,20 @@ this.examWindows = new Map(); } + const endpoint = this._resolveExamMessageEndpoint( + options && options.expectedUrl + ? options.expectedUrl + : (exam ? this.buildExamUrl(exam) : '') + ); this.examWindows.set(examId, { window: examWindow, startTime: Date.now(), status: 'active', expectedSessionId: null, - origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '', + expectedUrl: endpoint.expectedUrl, + expectedOrigin: endpoint.expectedOrigin, + allowOpaqueOrigin: endpoint.allowOpaqueOrigin, + observedOrigin: '', suiteSessionId: (options && options.suiteSessionId) ? options.suiteSessionId : null, suiteFlowMode: (options && options.suiteFlowMode) ? String(options.suiteFlowMode) : null, suiteSequenceIndex: Number.isInteger(options && options.sequenceIndex) ? options.sequenceIndex : null, @@ -8221,12 +8706,33 @@ console.warn('[App] 启动握手失败:', e); } - const emitInitEnvelope = () => { + const emitInitEnvelope = async () => { const windowInfo = this.ensureExamWindowSession(examId, examWindow); + // 让最早到达的 INIT 即携带 draft,避免无 draft 的 envelope 先被去重守卫登记, + // 从而使后续携带 draft 的 INIT 被当作重复而丢弃、草稿无法恢复。 + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo); try { - examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, examWindow, 'init_exam_session', initPayload); } catch (postError) { console.warn('[App] 跨源初始化题目窗口失败:', postError); } @@ -8293,6 +8799,9 @@ 'SUITE_CONFIG_UPDATE', 'VOCAB_HIGHLIGHT_SAVE', 'SIMULATION_DRAFT_SYNC', + 'READING_DRAFT_SYNC', + 'READING_ANNOTATION_SYNC', + 'PRACTICE_RECORD_SAVED', 'SIMULATION_NAVIGATE', 'SIMULATION_ACTIVE_EXAM_CHANGE', 'SIMULATION_SUBMIT' @@ -8422,29 +8931,44 @@ // 缺少来源窗口直接拒绝 if (!sourceWindow || !expectedWindow) { + this._reportExamMessageRejected(examId, '', 'missing-window', event); return; } - // 校验来源域,允许 file:// (origin 为 null) 与同源页面 - if (event.origin && event.origin !== 'null') { - const allowedOrigin = window.location && window.location.origin; - if (allowedOrigin && event.origin !== allowedOrigin) { - return; - } - } - const normalized = normalizeMessage(event.data); if (!normalized) { + this._reportExamMessageRejected(examId, '', 'invalid-envelope', event); return; } const windowInfo = this.ensureExamWindowSession(examId, expectedWindow); const expectedSessionId = windowInfo.expectedSessionId || ''; + // Most messages must still come from the exact exam window. A small + // suite/listening compatibility path below can prove an equivalent + // source with the window token and full session scope; do not reject + // before those constraints have been evaluated. + const sourceMatched = sourceWindow === expectedWindow; + const incomingOrigin = event && typeof event.origin === 'string' ? event.origin : ''; + if (windowInfo.expectedOrigin && windowInfo.expectedOrigin !== 'null') { + if (incomingOrigin !== windowInfo.expectedOrigin) { + this._reportExamMessageRejected(examId, normalized.type, 'origin-mismatch', event); + return; + } + } else if (windowInfo.allowOpaqueOrigin) { + if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') { + this._reportExamMessageRejected(examId, normalized.type, 'opaque-origin-mismatch', event); + return; + } + } else { + this._reportExamMessageRejected(examId, normalized.type, 'origin-unbound', event); + return; + } // 放宽消息源过滤,兼容 inline_collector 与 practice_page const src = normalized.sourceTag || ''; const allowedSources = new Set(['practice_page', 'inline_collector', 'suite_placeholder', 'listening_record_bridge']); - if (src && !allowedSources.has(src)) { + if (!src || !allowedSources.has(src)) { + this._reportExamMessageRejected(examId, normalized.type, 'source-tag-mismatch', event); return; // 非预期来源的消息忽略 } @@ -8494,6 +9018,16 @@ const expectedWindowSessionToken = windowInfo && typeof windowInfo.windowSessionToken === 'string' ? windowInfo.windowSessionToken.trim() : ''; + const permitsPreInitWithoutToken = type === 'REQUEST_INIT' + || (type === 'SESSION_READY' && data.initialized !== true); + if (!permitsPreInitWithoutToken && ( + !expectedWindowSessionToken + || !payloadWindowSessionToken + || payloadWindowSessionToken !== expectedWindowSessionToken + )) { + this._reportExamMessageRejected(examId, type, 'token-mismatch', event); + return; + } const canRoutePayloadExamInActiveSuite = Boolean( suiteRoutableMessageTypes.has(type) && isPayloadExamInActiveSuite @@ -8501,7 +9035,80 @@ && payloadSuiteSessionId && payloadSuiteSessionId === activeSuiteSessionId ); - const sourceMatched = isLikelySameWindowContext(sourceWindow, expectedWindow); + const isReadingAnnotationSync = type === 'READING_ANNOTATION_SYNC'; + const isReadingDraftSync = type === 'READING_DRAFT_SYNC'; + if (isReadingAnnotationSync) { + const expectedReviewSessionId = windowInfo && windowInfo.reviewSessionId + ? String(windowInfo.reviewSessionId) + : ''; + const payloadReviewSessionId = data && data.reviewSessionId != null + ? String(data.reviewSessionId) + : ''; + const payloadRecordId = data && data.recordId != null ? String(data.recordId) : ''; + const hasStrictSessionBinding = Boolean( + expectedSessionId + && payloadSessionId + && payloadSessionId === expectedSessionId + ); + const hasStrictWindowToken = Boolean( + expectedWindowSessionToken + && payloadWindowSessionToken + && payloadWindowSessionToken === expectedWindowSessionToken + ); + const hasStrictReviewBinding = Boolean( + windowInfo + && windowInfo.reviewMode + && expectedReviewSessionId + && payloadReviewSessionId === expectedReviewSessionId + ); + // 单篇阅读 final-submit 后,结果页以已存档 recordId 发送标注同步: + // 不在 review 回放态,但 windowInfo.submittedRecordId 必须与 payload + // recordId 严格匹配,并仍受 source/会话/窗口 token/题号约束。 + const hasSubmittedRecordBinding = Boolean( + windowInfo + && !windowInfo.reviewMode + && windowInfo.submittedRecordId + && payloadRecordId + && payloadRecordId === String(windowInfo.submittedRecordId) + ); + if ( + !sourceMatched + || !hasStrictSessionBinding + || !hasStrictWindowToken + || (!hasStrictReviewBinding && !hasSubmittedRecordBinding) + || !payloadExamId + || payloadExamId !== expectedExamId + ) { + return; + } + } + if (isReadingDraftSync) { + const hasStrictSessionBinding = Boolean( + expectedSessionId + && payloadSessionId + && payloadSessionId === expectedSessionId + ); + const hasStrictWindowToken = Boolean( + expectedWindowSessionToken + && payloadWindowSessionToken + && payloadWindowSessionToken === expectedWindowSessionToken + ); + const isLivePracticeWindow = Boolean( + windowInfo + && !windowInfo.reviewMode + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + ); + if ( + !sourceMatched + || !hasStrictSessionBinding + || !hasStrictWindowToken + || !isLivePracticeWindow + || !payloadExamId + || payloadExamId !== expectedExamId + ) { + return; + } + } const payloadWindowInfo = payloadExamId && payloadExamId !== expectedExamId && this.examWindows ? this.examWindows.get(payloadExamId) : null; @@ -8550,22 +9157,28 @@ const allowSuiteSourceFallback = Boolean( !sourceMatched && payloadExamId + && payloadSessionId + && expectedSessionId + && payloadSessionId === expectedSessionId && payloadTokenMatchesExpectedWindow && (payloadExamId === expectedExamId || isPayloadExamInActiveSuite) - && ( - (payloadSuiteSessionId && activeSuiteSessionId && payloadSuiteSessionId === activeSuiteSessionId) - || isExamInActiveSuite - ) + && payloadSuiteSessionId + && activeSuiteSessionId + && payloadSuiteSessionId === activeSuiteSessionId ); const allowListeningSourceFallback = Boolean( !sourceMatched && isListeningBridgeProtocolMessage - && ( - (payloadExamId && payloadExamId === expectedExamId) - || (payloadSessionId && expectedSessionId && payloadSessionId === expectedSessionId) - ) + && payloadTokenMatchesExpectedWindow + && payloadExamId + && payloadExamId === expectedExamId + && payloadSessionId + && expectedSessionId + && payloadSessionId === expectedSessionId + && (!payloadSuiteSessionId || !activeSuiteSessionId || payloadSuiteSessionId === activeSuiteSessionId) ); if (!sourceMatched && !allowSuiteSourceFallback && !allowListeningSourceFallback) { + this._reportExamMessageRejected(examId, type, 'window-mismatch', event); return; } if (windowInfo && sourceWindow && (sourceMatched || !expectedWindow || expectedWindow.closed)) { @@ -8648,8 +9261,19 @@ if (!data.sessionId && expectedSessionId) { data.sessionId = expectedSessionId; } + if ( + (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') + && ( + !String(data.submissionId || '').trim() + || !String(data.sessionId || '').trim() + || !String(payloadWindowSessionToken || '').trim() + ) + ) { + this._reportExamMessageRejected(examId, type, 'missing-submission-contract', event); + return; + } - windowInfo.origin = event.origin; + windowInfo.observedOrigin = event.origin; windowInfo.lastMessageAt = Date.now(); windowInfo.lastMessageType = type; if (payloadWindowSessionToken) { @@ -8721,18 +9345,34 @@ window.practiceConfig.suite = {}; } window.practiceConfig.suite.autoAdvanceAfterSubmit = autoAdvance; - try { - if (window.localStorage) { - window.localStorage.setItem('suite_auto_advance_after_submit', String(autoAdvance)); - } - } catch (_) { - // ignore storage write failures - } + await window.AppData.preferences.patchSuite({ autoAdvanceAfterSubmit: autoAdvance }); break; } case 'VOCAB_HIGHLIGHT_SAVE': - if (typeof window.saveReadingHighlightVocab === 'function') { - await window.saveReadingHighlightVocab(data); + if (!data || !String(data.requestId || '').trim()) { + this._reportExamMessageRejected(examId, type, 'missing-request-id', event); + break; + } + try { + const saved = typeof window.saveReadingHighlightVocab === 'function' + ? await window.saveReadingHighlightVocab(data) + : null; + this._announceVocabHighlightOutcome( + examId, + data, + sourceWindow || expectedWindow, + Boolean(saved), + saved ? '' : 'save_failed' + ); + } catch (saveError) { + console.warn('[VocabStore] 阅读高亮生词保存异常:', saveError); + this._announceVocabHighlightOutcome( + examId, + data, + sourceWindow || expectedWindow, + false, + 'save_failed' + ); } break; case 'REVIEW_NAVIGATE': @@ -8795,6 +9435,12 @@ } } break; + case 'READING_DRAFT_SYNC': + await this._queueReadingDraftSync(routedExamId, data, windowInfo); + break; + case 'READING_ANNOTATION_SYNC': + await this._queueReadingAnnotationSync(routedExamId, data, windowInfo); + break; case 'SIMULATION_NAVIGATE': if (typeof this._handleSimulationNavigate === 'function') { await this._handleSimulationNavigate(routedExamId, data, sourceWindow || expectedWindow); @@ -8865,12 +9511,31 @@ this.messageHandlers.set(examId, messageHandler); // 向题目窗口发送初始化消息(兼容 0.2 增强器监听的 INIT_SESSION) - const sendInitEnvelope = (targetWindow) => { + const sendInitEnvelope = async (targetWindow) => { try { const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo); - targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); } catch (initError) { console.warn('[App] 发送初始化消息失败:', initError); } @@ -8921,17 +9586,35 @@ let attempts = 0; const maxAttempts = 30; // ~9s - const tick = () => { + const tick = async () => { if (examWindow && !examWindow.closed) { try { const windowInfo = this.ensureExamWindowSession(examId, examWindow); + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo); windowInfo.handshakeAttempts = attempts + 1; windowInfo.lastHandshakeAt = Date.now(); this.examWindows && this.examWindows.set(examId, windowInfo); // 直接发送两种事件名,确保增强器任何实现都能收到 - examWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - examWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, examWindow, 'init_exam_session', initPayload); } catch (_) { /* 忽略 */ } } attempts++; @@ -8941,94 +9624,12 @@ console.warn('[App] 握手超时,练习页可能未加载增强器'); } }; - const timer = setInterval(tick, 300); + const timer = setInterval(() => { tick(); }, 300); this._handshakeTimers.set(examId, timer); // 立即发送一次 tick(); }, - /** - * 创建降级记录器 - */ - createFallbackRecorder() { - return { - handleRealPracticeData: async (examId, realData) => { - try { - // 获取题目信息 - const exam = await findExamDefinition(examId); - - if (!exam) { - console.error('[FallbackRecorder] 无法找到题目信息:', examId); - return null; - } - - const api = window.PracticeRecordAPI; - if (!api || typeof api.saveCompletion !== 'function') { - throw new Error('统一练习记录 API 未就绪'); - } - const practiceRecord = await api.saveCompletion(realData, { - examId, - sessionId: realData && realData.sessionId ? realData.sessionId : null, - examEntry: exam, - metadata: { - examId, - examTitle: exam.title || realData?.title || '', - category: exam.category || realData?.category || 'unknown', - frequency: exam.frequency || realData?.frequency || 'unknown', - type: exam.type || realData?.type || null - } - }); - - // 检查成就 - if (window.AchievementManager) { - window.AchievementManager.check(practiceRecord).catch(console.warn); - } - - return practiceRecord; - } catch (error) { - console.error('[FallbackRecorder] 保存失败:', error); - return null; - } - }, - - startSession: (examId) => { - // 简单的会话管理 - return { - examId: examId, - startTime: new Date().toISOString(), - sessionId: this.generateSessionId(examId), - status: 'started' - }; - }, - - getPracticeRecords: async (filters = {}) => { - try { - const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function' - ? await window.PracticeRecordAPI.list() - : []; - - if (Object.keys(filters).length === 0) { - return records; - } - - return records.filter(record => { - if (filters.examId && record.examId !== filters.examId) return false; - if (filters.category && record.category !== filters.category) return false; - if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false; - if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false; - if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false; - if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false; - - return true; - }); - } catch (error) { - console.error('[FallbackRecorder] 获取记录失败:', error); - return []; - } - } - }; - }, - // ExamBrowser组件已移除,使用内置的题目列表功能 /** @@ -9148,7 +9749,15 @@ }, generateWindowSessionToken(examId) { - const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; + const cryptoApi = global.crypto; + if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') { + throw new Error('Secure random generator is required for window session tokens'); + } + const bytes = new Uint8Array(24); + cryptoApi.getRandomValues(bytes); + const suffix = Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); const normalizedExamId = typeof examId === 'string' ? examId.trim().replace(/\s+/g, '-') : (examId != null ? String(examId).trim().replace(/\s+/g, '-') : ''); @@ -9551,6 +10160,27 @@ : (Array.isArray(entry.realData?.highlights) ? entry.realData.highlights.slice() : (Array.isArray(record.realData?.highlights) ? record.realData.highlights.slice() : []))); + const noteText = typeof entry.noteText === 'string' + ? entry.noteText + : (typeof entry.rawData?.noteText === 'string' + ? entry.rawData.noteText + : (typeof entry.realData?.noteText === 'string' + ? entry.realData.noteText + : (typeof record.realData?.noteText === 'string' ? record.realData.noteText : ''))); + const notes = Array.isArray(entry.notes) + ? this._cloneReviewData(entry.notes) + : (Array.isArray(entry.rawData?.notes) + ? this._cloneReviewData(entry.rawData.notes) + : (Array.isArray(entry.realData?.notes) + ? this._cloneReviewData(entry.realData.notes) + : (Array.isArray(record.realData?.notes) ? this._cloneReviewData(record.realData.notes) : []))); + const noteOutlines = Array.isArray(entry.noteOutlines) + ? this._cloneReviewData(entry.noteOutlines) + : (Array.isArray(entry.rawData?.noteOutlines) + ? this._cloneReviewData(entry.rawData.noteOutlines) + : (Array.isArray(entry.realData?.noteOutlines) + ? this._cloneReviewData(entry.realData.noteOutlines) + : (Array.isArray(record.realData?.noteOutlines) ? this._cloneReviewData(record.realData.noteOutlines) : []))); const scrollY = Number.isFinite(Number(entry.scrollY)) ? Number(entry.scrollY) : (Number.isFinite(Number(entry.rawData?.scrollY)) @@ -9584,6 +10214,9 @@ ? entryMetadata.markedQuestions.slice() : (Array.isArray(recordMetadata.markedQuestions) ? recordMetadata.markedQuestions.slice() : [])), highlights, + noteText, + notes, + noteOutlines, scrollY, metadata: mergedMetadata }; @@ -9600,6 +10233,22 @@ return this.reviewReplaySessions; }, + async _resolveReviewExamDefinition(entry) { + if (!entry || typeof entry !== 'object' || !entry.examId) { + throw new Error('历史记录缺少题目标识'); + } + if (typeof window.resolveExamForPracticeRecord !== 'function') { + throw new Error('历史记录题库解析器不可用'); + } + const exam = await window.resolveExamForPracticeRecord(entry); + if (exam) return exam; + // resolveExamForPracticeRecord 在记录缺 provenance 时已回退到当前活动题库解析 + // (见 libraryManager.resolveIndexForRecord)。走到这里说明 examId 在可解析的题库中 + // 确实不存在——统一按“题目不可用”处理,不再因缺少 libraryConfigurationId 而拒绝回放, + // 那会误伤所有 v1 迁移来、迁移时无法唯一判定来源的旧记录。 + throw new Error('该记录对应的题目在当前题库中不存在,可能题库已被删除或切换'); + }, + _buildReviewSession(record) { const entries = this._buildReviewReplayEntriesFromRecord(record); const validEntries = entries.filter((entry) => entry && entry.examId); @@ -9608,6 +10257,7 @@ } return { sessionId: `review_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + recordId: record && record.id != null ? String(record.id) : '', entries: validEntries, currentIndex: 0, windowRef: null, @@ -9615,6 +10265,492 @@ }; }, + _cloneReadingDraftValue(value) { + if (value == null) { + return value; + } + try { + return JSON.parse(JSON.stringify(value)); + } catch (_) { + if (Array.isArray(value)) { + return value.slice(); + } + if (value && typeof value === 'object') { + return Object.assign({}, value); + } + return value; + } + }, + + _readingDraftId(examId, libraryConfigurationId = null) { + const normalizedExamId = String(examId || '').trim(); + const normalizedConfigurationId = libraryConfigurationId == null + ? '' + : String(libraryConfigurationId).trim(); + return normalizedConfigurationId + ? `reading-draft:${normalizedExamId}:${normalizedConfigurationId}` + : `reading-draft:${normalizedExamId}`; + }, + + _buildReadingDraftSnapshot(examId, data = {}, windowInfo = null) { + const source = data && data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft) + ? data.draft + : (data && typeof data === 'object' ? data : {}); + const answers = source.answers && typeof source.answers === 'object' && !Array.isArray(source.answers) + ? this._cloneReadingDraftValue(source.answers) + : {}; + const highlights = Array.isArray(source.highlights) ? this._cloneReadingDraftValue(source.highlights) : []; + const notes = Array.isArray(source.notes) ? this._cloneReadingDraftValue(source.notes) : []; + const noteOutlines = Array.isArray(source.noteOutlines) ? this._cloneReadingDraftValue(source.noteOutlines) : []; + const markedQuestions = Array.isArray(source.markedQuestions) + ? this._cloneReadingDraftValue(source.markedQuestions) + : []; + const noteText = typeof source.noteText === 'string' ? source.noteText : ''; + const scrollY = Number.isFinite(Number(source.scrollY)) ? Math.max(0, Number(source.scrollY)) : 0; + const updatedAt = Number(data.draftUpdatedAt ?? source.updatedAt); + const sessionId = data.sessionId != null + ? String(data.sessionId) + : (windowInfo && windowInfo.expectedSessionId ? String(windowInfo.expectedSessionId) : ''); + const libraryConfigurationId = this._readLaunchLibraryConfigurationId(examId, windowInfo); + return { + id: this._readingDraftId(examId, libraryConfigurationId), + examId: String(examId), + libraryConfigurationId: libraryConfigurationId == null ? null : String(libraryConfigurationId), + sessionId, + answers, + highlights, + notes, + noteOutlines, + markedQuestions, + noteText, + scrollY, + updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(), + status: 'in_progress', + kind: 'reading_draft' + }; + }, + + async _readReadingDraftStore() { + const drafts = await window.AppData.recovery.listDrafts(); + const store = {}; + (Array.isArray(drafts) ? drafts : []).forEach((draft) => { + if (draft && draft.kind === 'reading_draft' && draft.examId) { + const id = draft.id || this._readingDraftId(draft.examId, draft.libraryConfigurationId); + store[String(id)] = draft; + } + }); + return store; + }, + + async _writeReadingDraftStore(store, changedDraft = null) { + try { + if (changedDraft) { + await window.AppData.recovery.saveDraft(changedDraft); + } + const drafts = await window.AppData.recovery.listDrafts(); + const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); + for (const draft of Array.isArray(drafts) ? drafts : []) { + const numericUpdatedAt = Number(draft && draft.updatedAt); + const draftUpdatedAt = Number.isFinite(numericUpdatedAt) + ? numericUpdatedAt + : Date.parse(draft && draft.updatedAt); + if ( + draft + && draft.kind === 'reading_draft' + && draft.id !== changedDraft?.id + && (!Number.isFinite(draftUpdatedAt) || draftUpdatedAt < cutoff) + ) { + await window.AppData.recovery.discardDraft(draft.id); + } + } + return true; + } catch (error) { + console.warn('[ReadingDraftGateway] 写入草稿失败:', error); + return false; + } + }, + + async handleReadingDraftSync(examId, data = {}, windowInfo = null) { + const info = windowInfo || (this.examWindows && this.examWindows.get(examId)); + if (!info || info.reviewMode) { + return false; + } + if (String(info.practiceMode || '').toLowerCase() === 'memorize') { + return false; + } + // 用“本窗口的 suite 绑定”判断是否套题草稿,而不是看全局 currentSuiteSession: + // 否则当任意套题会话仍活跃时,普通独立阅读窗口(windowInfo.suiteSessionId 为空) + // 的草稿也会被拒绝,关闭该窗口会丢失该题的在做答案/笔记。 + if (info.suiteSessionId) { + // Suite drafts stay on the suite session path. + return false; + } + const expectedSessionId = info.expectedSessionId ? String(info.expectedSessionId) : ''; + const payloadSessionId = data && data.sessionId != null ? String(data.sessionId) : ''; + if (!expectedSessionId || !payloadSessionId || payloadSessionId !== expectedSessionId) { + return false; + } + const draft = this._buildReadingDraftSnapshot(examId, data, info); + if (!draft.sessionId) { + return false; + } + // 必须在写队列里重新读取最新 store 再合并,否则并发不同 exam 的 write 会互相覆盖、 + // 后写者会丢掉前者的草稿(整个 map 是同一个存储 key,read-modify-write 非原子)。 + const store = await this._readReadingDraftStore(); + const previous = store[String(draft.id)] || null; + const previousNumericUpdatedAt = Number(previous && previous.updatedAt); + const previousUpdatedAt = Number.isFinite(previousNumericUpdatedAt) + ? previousNumericUpdatedAt + : Date.parse(previous && previous.updatedAt); + const nextNumericUpdatedAt = Number(draft.updatedAt); + const nextUpdatedAt = Number.isFinite(nextNumericUpdatedAt) + ? nextNumericUpdatedAt + : Date.parse(draft.updatedAt); + if ( + previous + && previous.sessionId === draft.sessionId + && Number.isFinite(previousUpdatedAt) + && Number.isFinite(nextUpdatedAt) + && nextUpdatedAt < previousUpdatedAt + ) { + return false; + } + store[String(draft.id)] = draft; + if (!await this._writeReadingDraftStore(store, draft)) { + return false; + } + info.lastReadingDraft = draft; + info.lastReadingDraftAt = Date.now(); + if (this.examWindows) { + this.examWindows.set(examId, info); + } + return true; + }, + + async _queueReadingDraftSync(examId, data = {}, windowInfo = null) { + // 同一宿主窗口内保持事件顺序;跨标签并发由 AppData/kernel CAS 处理。 + if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') { + this._readingDraftStoreQueue = Promise.resolve(); + } + const queued = this._readingDraftStoreQueue + .catch(() => undefined) + .then(() => this.handleReadingDraftSync(examId, data, windowInfo)); + this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => { + if (this._readingDraftStoreQueue === queued) { + this._readingDraftStoreQueue = Promise.resolve(); + } + }); + return queued; + }, + + async getReadingDraftForExam(examId, options = {}) { + const normalizedExamId = examId != null ? String(examId).trim() : ''; + if (!normalizedExamId) { + return null; + } + const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId') + ? options.libraryConfigurationId + : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo); + const store = await this._readReadingDraftStore(); + const draft = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null; + if (!draft || typeof draft !== 'object') { + return null; + } + // 仅用于“恢复未完成草稿”:跨开窗/重启时 expectedSessionId 会重新生成, + // 旧 draft 的 sessionId 必然与之不同;读取不写入任何数据,无跨会话覆盖风险, + // 因此这里不再用 sessionId 拦截,把旧草稿透传给调用方,由其在新 session 里继续答题。 + // 写/清路径仍保留严格校验,避免跨会话误覆盖或误删。 + const cloned = this._cloneReadingDraftValue(draft); + const expectedSessionId = options.sessionId != null ? String(options.sessionId) : ''; + if (expectedSessionId && String(cloned.sessionId || '') !== expectedSessionId) { + cloned.sessionId = expectedSessionId; + } + return cloned; + }, + + async clearReadingDraftForExam(examId, options = {}) { + const normalizedExamId = examId != null ? String(examId).trim() : ''; + if (!normalizedExamId) { + return false; + } + const libraryConfigurationId = Object.prototype.hasOwnProperty.call(options, 'libraryConfigurationId') + ? options.libraryConfigurationId + : this._readLaunchLibraryConfigurationId(normalizedExamId, options.windowInfo); + const run = async () => { + const store = await this._readReadingDraftStore(); + const existing = store[this._readingDraftId(normalizedExamId, libraryConfigurationId)] || null; + if (!existing) { + return false; + } + const expectedSessionId = options.sessionId != null ? String(options.sessionId) : ''; + // completion 路径用 acceptResumeSessionId=true 调用:若用户是在恢复的草稿上继续答题, + // 存档里仍是恢复前的旧 sessionId,而完成事件带的是新 session id; + // 这里已由完成事件本身做过严格的 message/session 校验,可直接删除该题草稿, + // 避免已提交的答案在重开 SAME 题时被旧草稿复活。 + if (expectedSessionId && String(existing.sessionId || '') !== expectedSessionId && !options.acceptResumeSessionId) { + return false; + } + await window.AppData.recovery.discardDraft(existing.id); + return true; + }; + // 与当前窗口的 draft sync 顺序一致,物理并发控制仍由 kernel 负责。 + if (!this._readingDraftStoreQueue || typeof this._readingDraftStoreQueue.then !== 'function') { + this._readingDraftStoreQueue = Promise.resolve(); + } + const queued = this._readingDraftStoreQueue + .catch(() => undefined) + .then(run); + this._readingDraftStoreQueue = queued.catch(() => undefined).then(() => { + if (this._readingDraftStoreQueue === queued) { + this._readingDraftStoreQueue = Promise.resolve(); + } + }); + return queued; + }, + + async _isPracticeCompletionPersisted(record) { + const identityFields = ['id', 'examId', 'sessionId']; + const completionTime = (value) => value && ( + value.endTime || value.completedAt || value.timestamp || value.date + ); + if (!record || typeof record !== 'object' + || identityFields.some((key) => record[key] == null || String(record[key]).trim() === '') + || !completionTime(record)) { + return false; + } + try { + const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' }); + if (!persisted || typeof persisted !== 'object') { + return false; + } + return identityFields.every((key) => String(persisted[key] ?? '') === String(record[key])) + && String(completionTime(persisted) || '') === String(completionTime(record)); + } catch (error) { + console.warn('[ReadingDraftGateway] 无法确认完成记录已落库,保留草稿:', error); + return false; + } + }, + + async handleReadingAnnotationSync(examId, data = {}, windowInfo = null) { + const info = windowInfo || (this.examWindows && this.examWindows.get(examId)); + if (!info) { + return false; + } + // 两条来源均可落库标注:①review 回放态,按 reviewSessionId 解析 recordId; + // ②单篇阅读 final-submit 后的结果页,按 windowInfo.submittedRecordId 直连 + // 已存档的练习记录。两者都需要 payload.recordId 与解析出的 recordId 严格匹配。 + let recordId = ''; + if (info.reviewMode && info.reviewSessionId) { + const reviewSessionId = String(info.reviewSessionId); + const sessions = this._ensureReviewReplayStore(); + const reviewSession = sessions.get(reviewSessionId); + if (!reviewSession || !reviewSession.recordId) { + return false; + } + recordId = String(reviewSession.recordId); + } else if (info.submittedRecordId) { + recordId = String(info.submittedRecordId); + } else { + return false; + } + if (data.recordId == null || String(data.recordId) !== recordId) { + return false; + } + + const source = data.annotations && typeof data.annotations === 'object' && !Array.isArray(data.annotations) + ? data.annotations + : data; + const annotationPatch = {}; + ['highlights', 'notes', 'noteOutlines', 'markedQuestions'].forEach((key) => { + if (Object.prototype.hasOwnProperty.call(source, key) && Array.isArray(source[key])) { + annotationPatch[key] = this._cloneReviewData(source[key]); + } + }); + if (Object.prototype.hasOwnProperty.call(source, 'noteText') && typeof source.noteText === 'string') { + annotationPatch.noteText = source.noteText; + } + if (Object.prototype.hasOwnProperty.call(source, 'scrollY')) { + const scrollY = Number(source.scrollY); + if (Number.isFinite(scrollY)) { + annotationPatch.scrollY = Math.max(0, scrollY); + } + } + if (Object.keys(annotationPatch).length === 0) { + return false; + } + + const normalizedExamId = String(examId); + await window.AppData.practice.updateAnnotations({ + recordId, + examId: normalizedExamId, + patch: annotationPatch, + operationId: data.operationId || data.messageId || undefined + }); + + // 只有 review 回放分支需要同时更新内存中的 reviewSession.entries; + // 单篇 submitted 直连已存档记录的分支不持有 reviewSession,跳过。 + if (info.reviewMode && info.reviewSessionId) { + const reviewSessionId = String(info.reviewSessionId); + const sessions = this._ensureReviewReplayStore(); + const reviewSession = sessions.get(reviewSessionId); + if (reviewSession && Array.isArray(reviewSession.entries)) { + reviewSession.entries = reviewSession.entries.map((entry) => ( + entry && String(entry.examId) === normalizedExamId + ? Object.assign({}, entry, annotationPatch) + : entry + )); + sessions.set(reviewSessionId, reviewSession); + } + } + return true; + }, + + async _queueReadingAnnotationSync(examId, data = {}, windowInfo = null) { + return this.handleReadingAnnotationSync(examId, data, windowInfo); + }, + + // 单篇阅读 final-submit 落库成功后,把已存档 recordId 写入 windowInfo 并 + // postMessage 回结果页,使结果页笔记改动能以 READING_ANNOTATION_SYNC + // 持久化回该练习记录。套题流程不会走到这里(已在 handleSuitePracticeComplete 早退)。 + _announceSubmittedReadingRecord(examId, savedRecord, completionData, sourceWindow) { + try { + const recordId = savedRecord && savedRecord.id != null ? String(savedRecord.id).trim() : ''; + if (!recordId) { + return false; + } + const sessionId = completionData && completionData.sessionId != null + ? String(completionData.sessionId) + : ''; + const targetWindow = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null; + if (!targetWindow) { + return false; + } + const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + if (windowInfo) { + windowInfo.submittedRecordId = recordId; + windowInfo.window = targetWindow; + windowInfo.status = 'completed'; + windowInfo.completedAt = windowInfo.completedAt || Date.now(); + this.examWindows && this.examWindows.set(examId, windowInfo); + } + this._postExamMessage(examId, targetWindow, 'PRACTICE_RECORD_SAVED', { + examId, + recordId, + sessionId: sessionId || null + }); + return true; + } catch (_) { + // annotation persistence hint is best-effort + return false; + } + }, + + _announcePracticeSubmitOutcome(examId, completionData, sourceWindow, succeeded, details = {}) { + const submissionId = completionData && completionData.submissionId != null + ? String(completionData.submissionId).trim() + : ''; + const sessionId = completionData && completionData.sessionId != null + ? String(completionData.sessionId).trim() + : ''; + const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null; + if (!submissionId || !sessionId || !targetWindow) { + return false; + } + try { + const type = succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED'; + const payload = { + examId, + submissionId, + sessionId, + suiteSessionId: completionData && completionData.suiteSessionId + ? String(completionData.suiteSessionId) + : null, + errorCode: succeeded ? null : String(details.errorCode || 'save_failed') + }; + const delivered = this._postExamMessage(examId, targetWindow, type, payload); + if (succeeded) { + const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + const receiptKey = `${sessionId}:${submissionId}`; + const receipts = windowInfo.practiceSubmitReceipts && typeof windowInfo.practiceSubmitReceipts === 'object' + ? windowInfo.practiceSubmitReceipts + : {}; + receipts[receiptKey] = Object.assign({}, payload, { examId, succeeded: true }); + const keys = Object.keys(receipts); + keys.slice(0, Math.max(0, keys.length - 8)).forEach((key) => delete receipts[key]); + windowInfo.practiceSubmitReceipts = receipts; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + return delivered; + } catch (error) { + console.warn('[DataCollection] 提交结果回执发送失败:', error); + return false; + } + }, + + _announceVocabHighlightOutcome(examId, requestData, sourceWindow, succeeded, errorCode = '') { + const requestId = requestData && requestData.requestId != null + ? String(requestData.requestId).trim() + : ''; + const sessionId = requestData && requestData.sessionId != null + ? String(requestData.sessionId).trim() + : ''; + const targetWindow = sourceWindow && !sourceWindow.closed ? sourceWindow : null; + if (!requestId || !sessionId || !targetWindow) { + return false; + } + return this._postExamMessage( + examId, + targetWindow, + succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED', + { + examId, + sessionId, + requestId, + errorCode: succeeded ? null : String(errorCode || 'save_failed') + } + ); + }, + + _replayPracticeSubmitReceipt(examId, completionData, sourceWindow) { + const submissionId = completionData && completionData.submissionId != null + ? String(completionData.submissionId).trim() + : ''; + const sessionId = completionData && completionData.sessionId != null + ? String(completionData.sessionId).trim() + : ''; + if (!submissionId || !sessionId || !sourceWindow || sourceWindow.closed) { + return false; + } + const windowInfo = this.ensureExamWindowSession(examId, sourceWindow); + const receipt = windowInfo.practiceSubmitReceipts + && windowInfo.practiceSubmitReceipts[`${sessionId}:${submissionId}`]; + if (!receipt || receipt.succeeded !== true) { + return false; + } + this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, true); + return true; + }, + + _scheduleSuiteSubmitTeardown(session) { + if (!session || typeof this._teardownSuiteSession !== 'function') { + return false; + } + if (session.submitReceiptTeardownTimer) { + clearTimeout(session.submitReceiptTeardownTimer); + } + const timer = setTimeout(() => { + session.submitReceiptTeardownTimer = null; + this._teardownSuiteSession(session).catch((teardownError) => { + console.warn('[SuitePractice] 提交回执重放窗口结束后清理套题会话失败:', teardownError); + }); + }, 30000); + session.submitReceiptTeardownTimer = timer; + if (timer && typeof timer.unref === 'function') { + timer.unref(); + } + return true; + }, + _bindReviewWindowRef(reviewSessionId, windowRef) { if (!reviewSessionId || !windowRef || windowRef.closed) { return; @@ -9656,14 +10792,15 @@ } const replayPayload = { reviewSessionId: session.sessionId, + recordId: session.recordId || null, reviewEntryIndex: safeIndex, readOnly: session.readOnly !== false, entry: this._cloneReviewData(entry) }; const contextPayload = this._buildReviewContextPayload(session, safeIndex); try { - targetWindow.postMessage({ type: 'REPLAY_PRACTICE_RECORD', data: replayPayload }, '*'); - targetWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'REPLAY_PRACTICE_RECORD', replayPayload); + this._postExamMessage(examId, targetWindow, 'REVIEW_CONTEXT', contextPayload); return true; } catch (error) { console.warn('[ReviewReplay] 向题目页发送回放数据失败:', error); @@ -9758,12 +10895,15 @@ console.warn('[ReviewReplay] 清理旧题目会话失败:', error); } + const examDefinition = await this._resolveReviewExamDefinition(nextEntry); await this.openExam(nextEntry.examId, { reviewMode: true, readOnly: true, reviewSessionId: sessionId, reviewEntryIndex: nextIndex, - reuseWindow: session.windowRef || null + reuseWindow: session.windowRef || null, + examDefinition, + requireRecordProvenance: true }); }, @@ -9781,11 +10921,14 @@ throw new Error('无法解析首题题目标识'); } + const examDefinition = await this._resolveReviewExamDefinition(firstEntry); const openedWindow = await this.openExam(firstEntry.examId, { reviewMode: true, readOnly: true, reviewSessionId: session.sessionId, - reviewEntryIndex: 0 + reviewEntryIndex: 0, + examDefinition, + requireRecordProvenance: true }); if (!openedWindow) { store.delete(session.sessionId); @@ -9804,6 +10947,14 @@ const suiteSessionId = typeof this._resolveSuiteSessionId === 'function' ? this._resolveSuiteSessionId(examId, info) : (info.suiteSessionId || null); + const activeSuite = suiteSessionId + && this.currentSuiteSession + && String(this.currentSuiteSession.id || '') === String(suiteSessionId) + ? this.currentSuiteSession + : null; + const autoAdvanceAfterSubmit = activeSuite && typeof activeSuite.autoAdvanceAfterSubmit === 'boolean' + ? activeSuite.autoAdvanceAfterSubmit + : (typeof info.autoAdvanceAfterSubmit === 'boolean' ? info.autoAdvanceAfterSubmit : null); const timerContext = typeof this._resolveSuiteTimerContext === 'function' ? this._resolveSuiteTimerContext({}, info) : { @@ -9815,14 +10966,20 @@ ? Math.floor(Number(extras.messageIssuedAtMs ?? extras.timestamp)) : Date.now(); info.lastInitMessageAt = messageIssuedAtMs; + // 启动时捕获的题库配置 ID:优先用 windowInfo 上预存值(启动时埋下), + // 否则从 mixin 私有 Map 兜底读,确保随 INIT_SESSION 携带到考试窗口。 + const launchLibraryConfigurationId = Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId') + ? info.libraryConfigurationId + : this._readLaunchLibraryConfigurationId(examId); const payload = { examId: examId, - parentOrigin: window.location.origin, + parentOrigin: info.allowOpaqueOrigin ? 'null' : window.location.origin, sessionId: info.expectedSessionId, windowSessionToken: info.windowSessionToken || null, messageIssuedAtMs, suiteSessionId: suiteSessionId || null, suiteFlowMode: info.suiteFlowMode || null, + autoAdvanceAfterSubmit, suiteTimerAnchorMs: timerContext.suiteTimerAnchorMs || null, globalTimerAnchorMs: timerContext.globalTimerAnchorMs || null, suiteTimerMode: timerContext.suiteTimerMode || null, @@ -9842,23 +10999,62 @@ reviewEntryIndex: Number.isInteger(info.reviewEntryIndex) ? info.reviewEntryIndex : 0, readOnly: Object.prototype.hasOwnProperty.call(info, 'readOnly') ? Boolean(info.readOnly) - : Boolean(info.reviewMode) + : Boolean(info.reviewMode), + libraryConfigurationId: launchLibraryConfigurationId }; + if ( + !payload.reviewMode + && !suiteSessionId + && !payload.suiteFlowMode + && info.lastReadingDraft + && typeof info.lastReadingDraft === 'object' + && String(info.lastReadingDraft.sessionId || '') === String(info.expectedSessionId || '') + ) { + payload.draft = this._cloneReadingDraftValue(info.lastReadingDraft); + } if (extras && typeof extras === 'object') { Object.assign(payload, extras); } + // extras 显式提供 libraryConfigurationId 时不被覆盖;若 extras 显式带 + // undefined/null(不应出现),保留启动捕获值以免丢失题库来源。 + if (extras && typeof extras === 'object' + && Object.prototype.hasOwnProperty.call(extras, 'libraryConfigurationId')) { + payload.libraryConfigurationId = extras.libraryConfigurationId; + } else if (payload.libraryConfigurationId === undefined) { + payload.libraryConfigurationId = launchLibraryConfigurationId; + } return payload; }, - _sendExamInitEnvelope(examId, targetWindow, extras = {}) { + async _sendExamInitEnvelope(examId, targetWindow, extras = {}) { if (!targetWindow || targetWindow.closed) { return null; } try { const windowInfo = this.ensureExamWindowSession(examId, targetWindow); + if ( + windowInfo + && !windowInfo.reviewMode + && !windowInfo.suiteSessionId + && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' + && typeof this.getReadingDraftForExam === 'function' + && !(extras && Object.prototype.hasOwnProperty.call(extras, 'draft')) + ) { + try { + const restoredDraft = await this.getReadingDraftForExam(examId, { + sessionId: windowInfo.expectedSessionId + }); + if (restoredDraft) { + windowInfo.lastReadingDraft = restoredDraft; + this.examWindows && this.examWindows.set(examId, windowInfo); + } + } catch (_) { + // draft restore is best-effort + } + } const initPayload = this._buildExamInitPayload(examId, windowInfo, extras); - targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); return initPayload; } catch (initError) { console.warn('[App] 发送初始化消息失败:', initError); @@ -9891,7 +11087,10 @@ expectedSessionId: this.generateSessionId(examId), windowSessionToken: null, windowSessionTokenSessionId: null, - origin: (typeof window !== 'undefined' && window.location) ? window.location.origin : '', + expectedUrl: '', + expectedOrigin: '', + allowOpaqueOrigin: false, + observedOrigin: '', suiteTimerAnchorMs: null, globalTimerAnchorMs: null, suiteTimerMode: null, @@ -9903,7 +11102,8 @@ reviewMode: false, reviewSessionId: null, reviewEntryIndex: 0, - readOnly: false + readOnly: false, + submittedRecordId: '' }); } @@ -9913,6 +11113,28 @@ windowInfo.window = examWindow; } + if (!windowInfo.expectedOrigin && examWindow) { + try { + const currentHref = examWindow.location && typeof examWindow.location.href === 'string' + ? examWindow.location.href + : ''; + const endpoint = this._resolveExamMessageEndpoint(currentHref); + const hostOrigin = window.location && window.location.origin; + const isTrustedSameOrigin = endpoint.expectedOrigin + && endpoint.expectedOrigin !== 'null' + && hostOrigin + && endpoint.expectedOrigin === hostOrigin; + const isTrustedLocalFile = endpoint.allowOpaqueOrigin && isFileProtocol; + if (isTrustedSameOrigin || isTrustedLocalFile) { + windowInfo.expectedUrl = endpoint.expectedUrl; + windowInfo.expectedOrigin = endpoint.expectedOrigin; + windowInfo.allowOpaqueOrigin = endpoint.allowOpaqueOrigin; + } + } catch (_) { + // Cross-origin WindowProxy locations are intentionally not probed further. + } + } + if (!windowInfo.expectedSessionId) { windowInfo.expectedSessionId = this.generateSessionId(examId); } @@ -9936,17 +11158,94 @@ return windowInfo; }, + /** + * 在考试启动时捕获当前激活的题库配置 ID,写入 windowInfo 与 mixin 私有 Map, + * 供后续 INIT_SESSION payload 以及 completeAttempt 路径使用,避免提交时再读取 + * 当前激活题库而拿到不一致的来源。 + * 该方法为 async:必要时调用方需 await。 + */ + async _captureLaunchLibraryConfigurationId(examId) { + if (!examId) return null; + if (!this._launchLibraryConfigurationIds) { + this._launchLibraryConfigurationIds = new Map(); + } + let configurationId = null; + try { + if (window.AppData && window.AppData.library + && typeof window.AppData.library.getActive === 'function') { + configurationId = await window.AppData.library.getActive(); + } + } catch (captureError) { + console.warn('[ExamSession] 捕获启动题库配置 ID 失败:', captureError); + configurationId = null; + } + const normalized = (configurationId === undefined || configurationId === null) + ? null + : configurationId; + this._launchLibraryConfigurationIds.set(String(examId), normalized); + // 同步作用中 windowInfo:避免后续 _buildExamInitPayload 等同步路径漏读 + try { + if (this.examWindows && this.examWindows.has(examId)) { + const windowInfo = this.examWindows.get(examId); + if (windowInfo && typeof windowInfo === 'object' + && !Object.prototype.hasOwnProperty.call(windowInfo, 'libraryConfigurationId')) { + windowInfo.libraryConfigurationId = normalized; + } + } + } catch (_) { /* 忽略:windowInfo 不存在不影响捕获 */ } + return normalized; + }, + + /** + * 同步读取指定 examId 启动时捕获的题库配置 ID;若无捕获返回 null。 + * 优先取实时注入(realData.metadata / payload 显式传入)的值,再回退到启动时捕获值。 + */ + _readLaunchLibraryConfigurationId(examId, ...fromSources) { + for (const source of fromSources) { + if (source !== undefined && source !== null && typeof source === 'object') { + const metadata = source.metadata; + const direct = Object.prototype.hasOwnProperty.call(source, 'libraryConfigurationId') + ? source.libraryConfigurationId + : (metadata && Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId')) + ? metadata.libraryConfigurationId + : undefined; + if (direct !== undefined && direct !== null) { + return direct; + } + } + } + if (!this._launchLibraryConfigurationIds) { + return null; + } + return this._launchLibraryConfigurationIds.get(String(examId)) || null; + }, + + /** + * 清除指定 examId 启动时捕获的题库配置 ID(窗口关闭后调用)。 + */ + _discardLaunchLibraryConfigurationId(examId) { + if (this._launchLibraryConfigurationIds && examId) { + this._launchLibraryConfigurationIds.delete(String(examId)); + } + }, + _syncRecorderSessionStarted(examId, windowInfo, metadata = {}) { const recorder = this.components && this.components.practiceRecorder; if (!recorder || typeof recorder.handleSessionStarted !== 'function') { return; } const sessionId = (windowInfo && windowInfo.expectedSessionId) || this.generateSessionId(examId); + // 注入启动时捕获的题库配置 ID,确保 recorder 会话上携带来源。 + const mergedMetadata = Object.assign({}, metadata); + if (!Object.prototype.hasOwnProperty.call(mergedMetadata, 'libraryConfigurationId')) { + mergedMetadata.libraryConfigurationId = + this._readLaunchLibraryConfigurationId(examId, windowInfo, metadata); + } try { recorder.handleSessionStarted({ examId, sessionId, - metadata + metadata: mergedMetadata }); } catch (recorderError) { console.warn('[PracticeRecorder] 重置后同步会话状态失败:', recorderError); @@ -9955,16 +11254,25 @@ async _removeActiveExamSessionMetadata(examId) { try { - const activeSessions = await storage.get('active_sessions', []); - const updatedSessions = Array.isArray(activeSessions) - ? activeSessions.filter(session => session && session.examId !== examId) - : []; - await storage.set('active_sessions', updatedSessions); + await this._discardActiveSessionsForExam(examId); } catch (error) { console.warn('[App] 清理活动会话元数据失败:', error); } }, + async _discardActiveSessionsForExam(examId) { + const activeSessions = await window.AppData.recovery.listActiveSessions(); + const matches = (Array.isArray(activeSessions) ? activeSessions : []) + .filter((session) => session && session.examId === examId); + for (const session of matches) { + const entityId = session.id || session.sessionId || session.recordId; + if (entityId) { + await window.AppData.recovery.discardActiveSession(entityId); + } + } + return matches.length; + }, + _isResetCapableUnifiedReadingCompletion(data, sourceWindow = null) { if (!sourceWindow || sourceWindow.closed) { return false; @@ -10018,6 +11326,7 @@ windowInfo.reviewMode = false; windowInfo.readOnly = false; windowInfo.status = 'active'; + windowInfo.submittedRecordId = ''; this.examWindows && this.examWindows.set(examId, windowInfo); await this.openExam(examId, { target: 'tab', @@ -10038,6 +11347,7 @@ windowInfo.reviewSessionId = null; windowInfo.reviewEntryIndex = 0; windowInfo.readOnly = false; + windowInfo.submittedRecordId = ''; windowInfo.dataCollectorReady = false; windowInfo.lastResetAt = Date.now(); windowInfo.lastResetReason = reason || 'reset'; @@ -10051,7 +11361,7 @@ resetReason: reason || 'reset' }); - this._sendExamInitEnvelope(examId, targetWindow, { + await this._sendExamInitEnvelope(examId, targetWindow, { practiceMode: null, reviewMode: false, readOnly: false @@ -10072,6 +11382,15 @@ } try { + const windowInfo = this.examWindows && this.examWindows.get(examId); + const hostSessionId = windowInfo && windowInfo.expectedSessionId + ? String(windowInfo.expectedSessionId) + : this.generateSessionId(examId); + if (windowInfo && !windowInfo.expectedSessionId) { + windowInfo.expectedSessionId = hostSessionId; + this.examWindows.set(examId, windowInfo); + } + // 优先使用新的练习页面管理器 if (window.practicePageManager) { const sessionId = await window.practicePageManager.startPracticeSession(examId, exam); @@ -10083,27 +11402,46 @@ // 使用练习记录器开始会话 if (this.components.practiceRecorder) { + // 把启动时捕获的题库配置 ID 透传给 recorder,确保会话 metadata 来源稳定。 + const launchLibraryConfigurationId = this._readLaunchLibraryConfigurationId(examId); + const startPayload = Object.assign({}, exam, { + sessionId: hostSessionId, + libraryConfigurationId: launchLibraryConfigurationId + }); let sessionData; if (typeof this.components.practiceRecorder.startPracticeSession === 'function') { - sessionData = this.components.practiceRecorder.startPracticeSession(examId, exam); + sessionData = this.components.practiceRecorder.startPracticeSession( + examId, + startPayload + ); } else if (typeof this.components.practiceRecorder.startSession === 'function') { - sessionData = this.components.practiceRecorder.startSession(examId, exam); + sessionData = this.components.practiceRecorder.startSession( + examId, + startPayload + ); } else { console.warn('[App] PracticeRecorder没有可用的启动方法'); sessionData = null; } + if (sessionData && sessionData.sessionId && windowInfo + && windowInfo.expectedSessionId !== sessionData.sessionId) { + // Keep host token/session aligned with whatever the recorder accepted. + windowInfo.expectedSessionId = String(sessionData.sessionId); + this._refreshExamWindowToken(examId, windowInfo); + this.examWindows.set(examId, windowInfo); + } } else { // 降级处理 + const sessionId = hostSessionId; const sessionData = { + id: `active-session:${sessionId}`, examId: examId, startTime: new Date().toISOString(), status: 'started', - sessionId: this.generateSessionId(examId) + sessionId }; - const activeSessions = await storage.get('active_sessions', []); - activeSessions.push(sessionData); - await storage.set('active_sessions', activeSessions); + await window.AppData.recovery.saveActiveSession(sessionData); } // 更新题目状态 @@ -10113,7 +11451,7 @@ console.error('[App] 启动练习会话失败:', error); // 最终降级方案 - this.startPracticeSessionFallback(examId, exam); + await this.startPracticeSessionFallback(examId, exam); } }, @@ -10121,17 +11459,16 @@ * 降级启动练习会话 */ async startPracticeSessionFallback(examId, exam) { - + const sessionId = this.generateSessionId(examId); const sessionData = { + id: `active-session:${sessionId}`, examId: examId, startTime: new Date().toISOString(), status: 'started', - sessionId: this.generateSessionId(examId) + sessionId }; - const activeSessions = await storage.get('active_sessions', []); - activeSessions.push(sessionData); - await storage.set('active_sessions', activeSessions); + await window.AppData.recovery.saveActiveSession(sessionData); // 更新题目状态 this.updateExamStatus(examId, 'in-progress'); @@ -10187,9 +11524,9 @@ || payload.metadata?.source === 'listening_record_bridge' || payload.pageType === 'listening' || payload.type === 'listening'; - const isPreInitListeningReady = Boolean( - isListeningBridgeReady - && payload.initialized === false + const isPreInitReady = (isListeningBridgeReady && payload.initialized === false) || ( + !String(payload.windowSessionToken || '').trim() + && payload.pageType === 'suite-placeholder' ); // 更新会话状态 @@ -10203,15 +11540,15 @@ if (windowInfo) { if (isListeningBridgeReady) { windowInfo.listeningBridgeSeen = true; - windowInfo.listeningBridgeInitialized = !isPreInitListeningReady; + windowInfo.listeningBridgeInitialized = !isPreInitReady; } - if (!isPreInitListeningReady) { + if (!isPreInitReady) { windowInfo.dataCollectorReady = true; } if (payload.pageType) { windowInfo.pageType = payload.pageType; } - if (!isPreInitListeningReady && payload.sessionId && windowInfo.expectedSessionId !== payload.sessionId) { + if (!isPreInitReady && payload.sessionId && windowInfo.expectedSessionId !== payload.sessionId) { windowInfo.expectedSessionId = payload.sessionId; } if (payload.suiteSessionId && !windowInfo.suiteSessionId) { @@ -10235,16 +11572,16 @@ this.examWindows && this.examWindows.set(examId, windowInfo); } - if (isPreInitListeningReady) { + if (isPreInitReady) { try { const targetWindow = (windowInfo && windowInfo.window) || null; if (targetWindow && typeof targetWindow.postMessage === 'function') { const initPayload = this._buildExamInitPayload(examId, windowInfo || {}); - targetWindow.postMessage({ type: 'INIT_SESSION', data: initPayload }, '*'); - targetWindow.postMessage({ type: 'init_exam_session', data: initPayload }, '*'); + this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); + this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); } } catch (initError) { - console.warn('[App] 听力桥预初始化 ready 后补发 INIT_SESSION 失败:', initError); + console.warn('[App] 预初始化 ready 后补发 INIT_SESSION 失败:', initError); } return; } @@ -10257,6 +11594,25 @@ } } + // 手动回看模式的页面可能先以普通 P1/P2 页面类型上报 SESSION_READY, + // 不应依赖 suiteExamMap/页面类型白名单才能补发回看上下文。 + const activeSuite = this.currentSuiteSession; + const stationarySuiteExam = Boolean( + activeSuite + && activeSuite.status === 'active' + && activeSuite.flowMode === 'stationary' + && Array.isArray(activeSuite.sequence) + && activeSuite.sequence.some(item => item && item.examId === examId) + ); + if (stationarySuiteExam && typeof this._sendSuiteReviewState === 'function') { + const targetWindow = windowInfo && windowInfo.window ? windowInfo.window : null; + try { + this._sendSuiteReviewState(activeSuite, examId, targetWindow); + } catch (suiteContextError) { + console.warn('[SuitePractice] 手动回看页面 ready 后补发上下文失败:', suiteContextError); + } + } + if (!(windowInfo && windowInfo.reviewMode) && this.components && this.components.practiceRecorder @@ -10270,7 +11626,9 @@ pageType: payload.pageType || null, url: payload.url || null, title: payload.title || null, - suiteSessionId: payload.suiteSessionId || null + suiteSessionId: payload.suiteSessionId || null, + // 此处是练习页 SESSION_READY 后同步会话状态的时刻,注入启动时捕获的题库配置 ID。 + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, payload, windowInfo) } }); } catch (recorderError) { @@ -10333,11 +11691,7 @@ return signals.includes('listening_record_bridge') || signals.includes('listening'); }, - _ensureRecorderSessionForListeningCompletion(examId, data) { - if (!this._isListeningBridgeCompletionPayload(data)) { - return; - } - + _ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow = null, defaults = {}) { const recorder = this.components && this.components.practiceRecorder; if (!recorder) { return; @@ -10363,18 +11717,35 @@ && typeof recorder.activeSessions.has === 'function' && recorder.activeSessions.has(examId) ); + const pageType = defaults.pageType + || data?.pageType + || data?.metadata?.pageType + || data?.metadata?.type + || data?.type + || 'practice'; + const practiceType = defaults.type + || data?.type + || data?.metadata?.type + || data?.metadata?.examType + || pageType; + const source = defaults.source + || data?.source + || data?.metadata?.source + || 'practice_page'; if (!hasActiveSession && typeof recorder.startPracticeSession === 'function') { try { recorder.startPracticeSession(examId, { + sessionId, title: data?.title || data?.metadata?.examTitle || '', category: data?.category || data?.pageType || data?.metadata?.category || '', frequency: data?.frequency || data?.metadata?.frequency || '', - type: 'listening', - totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0 + type: practiceType, + totalQuestions: data?.scoreInfo?.total || data?.totalQuestions || 0, + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo) }); } catch (startError) { - console.warn('[PracticeRecorder] 听力完成前补建会话失败:', startError); + console.warn('[PracticeRecorder] 完成前补建会话失败:', startError); } } @@ -10384,17 +11755,18 @@ examId, sessionId, metadata: { - pageType: data?.pageType || 'listening', - type: 'listening', - examType: 'listening', + pageType, + type: practiceType, + examType: defaults.examType || practiceType, url: data?.url || data?.metadata?.url || null, title: data?.title || data?.metadata?.examTitle || null, suiteSessionId: data?.suiteSessionId || data?.metadata?.suiteSessionId || null, - source: data?.source || data?.metadata?.source || 'listening_record_bridge' + source, + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, data, windowInfo) } }); } catch (startedError) { - console.warn('[PracticeRecorder] 听力完成前同步会话状态失败:', startedError); + console.warn('[PracticeRecorder] 完成前同步会话状态失败:', startedError); } } }, @@ -10436,6 +11808,9 @@ console.info('[ReadingMemorize] 背题模式完成事件不保存为正式练习记录:', examId); return; } + if (this._replayPracticeSubmitReceipt(examId, data, sourceWindow)) { + return true; + } // 听力桥返回的填空答案直接按 answerComparison 检测,不能依赖题源目录名必须包含 P1/P4。 try { @@ -10477,6 +11852,10 @@ console.warn('[DataCollection] 拼写错误检测失败,已忽略:', error); } this._normalizeListeningSpellingErrors(examId, data); + // Reading/placeholder completions need the same active-session rebind that + // listening already performed: hot-upgraded PracticeRecorder instances otherwise + // reject production saves when activeSessions was empty. + this._ensureRecorderSessionForPracticeCompletion(examId, data, sourceWindow); let suiteHandlerDeclined = false; const payloadSuiteSessionId = ( @@ -10499,9 +11878,21 @@ if (shouldDelegateToSuiteHandler && typeof this.handleSuitePracticeComplete === 'function') { try { - const handled = await this.handleSuitePracticeComplete(examId, data, sourceWindow); + const suiteOutcome = await this.handleSuitePracticeComplete(examId, data, sourceWindow); + const handled = suiteOutcome === true || Boolean(suiteOutcome && suiteOutcome.handled); if (handled) { - return; + const committed = !suiteOutcome || typeof suiteOutcome !== 'object' || suiteOutcome.committed !== false; + this._announcePracticeSubmitOutcome(examId, data, sourceWindow, committed, { + errorCode: suiteOutcome && suiteOutcome.errorCode + }); + if (committed && suiteOutcome && suiteOutcome.teardownSession && typeof this._teardownSuiteSession === 'function') { + try { + this._scheduleSuiteSubmitTeardown(suiteOutcome.teardownSession); + } catch (teardownError) { + console.warn('[SuitePractice] 套题已提交,但延迟清理调度失败:', teardownError); + } + } + return committed; } suiteHandlerDeclined = true; } catch (suiteError) { @@ -10518,18 +11909,65 @@ metadata: Object.assign({}, data?.metadata || {}, { allowStandaloneSave: true, suiteRecovery: true }) }) : data; - this._ensureRecorderSessionForListeningCompletion(examId, completionData); + // The generic completion rebind above already covers listening payloads. + let completionCommitted = false; + let completedViaFallback = false; try { + let persistedRecord = null; if (recorder && typeof recorder.handleSessionCompleted === 'function') { try { - await recorder.handleSessionCompleted(completionData); + persistedRecord = await recorder.handleSessionCompleted(completionData); } catch (recErr) { console.warn('[DataCollection] PracticeRecorder 完成事件处理失败,改用降级存储:', recErr); - await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + completedViaFallback = true; } } else { - await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + persistedRecord = await this.saveRealPracticeData(examId, completionData, { savingAsFallback: true }); + completedViaFallback = true; + } + + if (!persistedRecord || typeof persistedRecord !== 'object' || !String(persistedRecord.id || '').trim()) { + throw new Error('Practice completion returned without a committed record'); + } + + let completionReadable = false; + if (typeof this._isPracticeCompletionPersisted === 'function') { + try { + completionReadable = await this._isPracticeCompletionPersisted(persistedRecord); + } catch (verificationError) { + console.warn('[DataCollection] 练习记录提交后回读失败,不影响已提交结果:', verificationError); + } + } + if (!completionReadable) { + throw new Error('Practice completion could not be verified in canonical storage'); + } + completionCommitted = true; + + if (completedViaFallback && recorder && typeof recorder.endPracticeSession === 'function') { + recorder.endPracticeSession(examId); + } + + // 单篇阅读 final-submit 落库成功后,把已存档 recordId 回传给结果页, + // 使其可以在只读提交态编辑笔记并以 READING_ANNOTATION_SYNC 持久化回该记录。 + // 套题流程在上方的 handleSuitePracticeComplete 分支已 return,不会走到这里。 + this._announceSubmittedReadingRecord(examId, persistedRecord, completionData, sourceWindow); + this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, true); + + if (typeof this.clearReadingDraftForExam === 'function') { + try { + await this.clearReadingDraftForExam(examId, { + sessionId: completionData && completionData.sessionId + ? String(completionData.sessionId) + : null, + // 完成事件已通过严格的 message/session 校验,删除该题草稿时 + // 允许命中“恢复前的旧 session id”的存档,避免已提交答案被复活。 + acceptResumeSessionId: true + }); + } catch (_) { + // draft cleanup is best-effort + } } // 刷新内存中的练习记录,确保无需手动刷新即可看到 @@ -10537,9 +11975,17 @@ try { if (typeof window.syncPracticeRecords === 'function') { await window.syncPracticeRecords({ forceRender: true }); - } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const latest = await window.PracticeRecordAPI.list(); - this.setState('practice.records', Array.isArray(latest) ? latest : []); + } else { + const [latest, index] = await Promise.all([ + window.AppData.practice.list({ projection: 'light' }), + window.resolveActiveLibraryIndex() + ]); + if (typeof window.refreshBrowseProgressFromRecords === 'function') { + window.refreshBrowseProgressFromRecords(latest, index); + } + if (typeof window.updatePracticeView === 'function') { + window.updatePracticeView(latest, index); + } } } catch (syncErr) { console.error('[DataCollection] 刷新练习记录失败(数据已保存,不影响落库结果):', syncErr); @@ -10562,26 +12008,31 @@ // 显示完成通知(使用真实数据) await this.showRealCompletionNotification(examId, data); - // 检查成就 + // 检查成就(解锁判定由 achievements.progress projector 负责,这里只读取差异并提示) if (window.AchievementManager) { - window.AchievementManager.check(data?.realData).catch(console.warn); - } - - // 刷新练习记录显示 - if (typeof updatePracticeView === 'function') { - updatePracticeView(); + window.AchievementManager.check().catch(console.warn); } } catch (error) { console.error('[DataCollection] 处理练习完成数据失败:', error); window.showMessage && window.showMessage('练习记录保存失败,请稍后重试', 'error'); + this._announcePracticeSubmitOutcome(examId, completionData, sourceWindow, false, { + errorCode: 'save_failed' + }); } finally { - if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) { - await this.retainExamWindowAfterCompletion(examId, sourceWindow, completionData); - } else { - this.cleanupExamSession(examId); + if (completionCommitted) { + try { + if (this._isResetCapableUnifiedReadingCompletion(completionData, sourceWindow)) { + await this.retainExamWindowAfterCompletion(examId, sourceWindow, completionData); + } else { + await this.cleanupExamSession(examId); + } + } catch (cleanupError) { + console.warn('[DataCollection] 练习已提交,但会话清理失败:', cleanupError); + } } } + return completionCommitted; }, /** @@ -10598,12 +12049,7 @@ type: 'data_collection_error' }; - const errorLogs = await storage.get('collection_errors', []); - errorLogs.push(errorInfo); - if (errorLogs.length > 50) { - errorLogs.splice(0, errorLogs.length - 50); - } - await storage.set('collection_errors', errorLogs); + console.warn('[DataCollection] 诊断信息:', errorInfo); // 标记该会话使用模拟数据 if (this.examWindows && this.examWindows.has(examId)) { @@ -10682,17 +12128,16 @@ throw new Error(`无法找到题目信息: ${examId}`); } - const api = window.PracticeRecordAPI; - if (!api || typeof api.saveCompletion !== 'function') { - throw new Error('统一练习记录 API 未就绪'); - } - const metadata = Object.assign({}, realData?.metadata || {}, { examId, examTitle: exam.title || realData?.title || '', category: exam.category || realData?.category || realData?.metadata?.category || 'unknown', frequency: exam.frequency || realData?.frequency || realData?.metadata?.frequency || 'unknown', - type: exam.type || realData?.type || realData?.practiceType || null + type: exam.type || realData?.type || realData?.practiceType || null, + // 启动时捕获的题库配置 ID;优先取 realData.metadata 显式值,再回退到启动时 + // 在 openExam 捕获的 mixin 私有 Map 值,最后显式随 metadata 写入为 null, + // 让记录来源稳定不受到提交时当前激活题库的影响。 + libraryConfigurationId: this._readLaunchLibraryConfigurationId(examId, realData) }); const payload = Object.assign({}, realData, { @@ -10704,19 +12149,17 @@ metadata }); - const savedRecord = await api.saveCompletion(payload, { - examId, - sessionId: payload.sessionId || realData?.sessionId || null, - examEntry: exam, - metadata - }, exam, { - currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '1.0.0', - maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000, - updateStats: true + const receipt = await window.AppData.practice.completeAttempt({ + record: payload, + operationId: payload.operationId + || payload.messageId + || (payload.submissionId + ? `practice-complete:${String(payload.examId || examId)}:${String(payload.sessionId || 'session')}:${String(payload.submissionId)}` + : undefined) }); console.log('[DataCollection] 练习完成数据已保存到 canonical store'); - return savedRecord; + return receipt.record; } catch (error) { console.error('[DataCollection] 保存真实数据失败:', error); throw error; @@ -10969,9 +12412,7 @@ } // 清理活动会话 - const activeSessions = await storage.get('active_sessions', []); - const updatedSessions = activeSessions.filter(session => session.examId !== examId); - await storage.set('active_sessions', updatedSessions); + await this._discardActiveSessionsForExam(examId); }, /** @@ -11153,7 +12594,7 @@ * 显示活动会话详情 */ async showActiveSessionsDetails() { - const activeSessions = await storage.get('active_sessions', []); + const activeSessions = await window.AppData.recovery.listActiveSessions(); const examIndex = await getActiveExamIndexSnapshot(); if (activeSessions.length === 0) { @@ -11244,7 +12685,7 @@ * 关闭所有题目会话 */ async closeAllExamSessions() { - const activeSessions = await storage.get('active_sessions', []); + const activeSessions = await window.AppData.recovery.listActiveSessions(); activeSessions.forEach(session => { this.closeExamSession(session.examId); @@ -11375,29 +12816,15 @@ } }; - function getActiveExamIndex() { - try { - if (typeof global.getExamIndexState === 'function') { - const state = global.getExamIndexState(); - return Array.isArray(state) ? state : []; - } - } catch (_) { } - return Array.isArray(global.examIndex) ? global.examIndex : null; - } - function hasListeningEntries(index) { return (Array.isArray(index) ? index : []).some((exam) => { return exam && exam.type === 'listening'; }); } - function hasActiveListeningLibrary() { + function hasActiveListeningLibrary(index) { if (typeof global.hasActiveListeningLibrary === 'function') { - return global.hasActiveListeningLibrary(); - } - const index = getActiveExamIndex(); - if (index === null) { - return true; + return global.hasActiveListeningLibrary(index); } return hasListeningEntries(index); } @@ -11427,7 +12854,7 @@ * 初始化控制器 * @param {string} containerId - 按钮容器的DOM ID */ - initialize(containerId = 'type-filter-buttons') { + initialize(containerId = 'type-filter-buttons', examIndex = []) { this.buttonContainer = document.getElementById(containerId); if (!this.buttonContainer) { console.warn('[BrowseController] 按钮容器未找到:', containerId); @@ -11435,10 +12862,10 @@ } // 从全局状态恢复模式 - this.restoreMode(); + this.restoreMode(examIndex); // 渲染初始按钮 - this.renderFilterButtons(); + this.renderFilterButtons(examIndex); return true; } @@ -11447,7 +12874,7 @@ * 设置浏览模式 * @param {string} mode - 模式ID (default | frequency-p1 | frequency-p4) */ - setMode(mode) { + setMode(mode, examIndex = []) { if (isReadingMemorizeBrowseMode()) { mode = 'default'; } @@ -11456,7 +12883,7 @@ return; } - const nextMode = isListeningMode(mode) && !hasActiveListeningLibrary() + const nextMode = isListeningMode(mode) && !hasActiveListeningLibrary(examIndex) ? 'default' : mode; this.currentMode = nextMode; @@ -11466,10 +12893,10 @@ this.saveMode(); // 重新渲染按钮 - this.renderFilterButtons(); + this.renderFilterButtons(examIndex); // 应用筛选 - this.applyFilter(this.activeFilter); + this.applyFilter(this.activeFilter, examIndex); } /** @@ -11483,13 +12910,13 @@ /** * 渲染筛选按钮 */ - renderFilterButtons() { + renderFilterButtons(examIndex = []) { if (!this.buttonContainer) { return; } const config = this.getCurrentModeConfig(); - const filters = this.getVisibleFilters(config); + const filters = this.getVisibleFilters(config, examIndex); if (!filters.some((filter) => filter.id === this.activeFilter)) { this.activeFilter = filters.length ? filters[0].id : 'all'; } @@ -11521,8 +12948,13 @@ button.setAttribute('aria-pressed', filter.id === this.activeFilter ? 'true' : 'false'); // 绑定点击事件 - button.addEventListener('click', () => { - this.handleFilterClick(filter.id); + button.addEventListener('click', async () => { + try { + const index = await global.resolveActiveLibraryIndex(); + this.handleFilterClick(filter.id, index); + } catch (error) { + console.error('[BrowseController] 读取活动题库失败:', error); + } }); this.buttonContainer.appendChild(button); @@ -11534,16 +12966,16 @@ } } - getVisibleFilters(config) { + getVisibleFilters(config, examIndex = []) { const normalized = config || this.getCurrentModeConfig(); const filters = Array.isArray(normalized.filters) ? normalized.filters : []; if (isReadingMemorizeBrowseMode()) { return BROWSE_MODES.default.filters.filter((filter) => filter.type === 'reading'); } - if (normalized.id === 'default' && !hasActiveListeningLibrary()) { + if (normalized.id === 'default' && !hasActiveListeningLibrary(examIndex)) { return filters.filter((filter) => filter.type !== 'listening'); } - if (isListeningMode(normalized.id) && !hasActiveListeningLibrary()) { + if (isListeningMode(normalized.id) && !hasActiveListeningLibrary(examIndex)) { return BROWSE_MODES.default.filters.filter((filter) => filter.type !== 'listening'); } return filters.slice(); @@ -11553,14 +12985,14 @@ * 处理筛选按钮点击 * @param {string} filterId - 筛选器ID */ - handleFilterClick(filterId) { + handleFilterClick(filterId, examIndex = []) { this.activeFilter = filterId; // 更新按钮激活状态 this.updateButtonStates(); // 应用筛选 - this.applyFilter(filterId); + this.applyFilter(filterId, examIndex); } /** @@ -11588,15 +13020,15 @@ * 应用筛选 * @param {string} filterId - 筛选器ID */ - applyFilter(filterId) { + applyFilter(filterId, examIndex = []) { const config = this.getCurrentModeConfig(); if (config.filterLogic === 'type-based') { // 默认模式:按类型筛选 - this.filterByType(filterId); + this.filterByType(filterId, examIndex); } else if (config.filterLogic === 'folder-based') { // 频率模式:按文件夹筛选 - this.filterByFolder(filterId); + this.filterByFolder(filterId, examIndex); } } @@ -11604,10 +13036,10 @@ * 按类型筛选(默认模式) * @param {string} type - 类型 (all | reading | listening) */ - filterByType(type) { + filterByType(type, examIndex = []) { // 调用全局的 filterByType 函数 if (typeof global.filterByType === 'function') { - global.filterByType(type); + global.filterByType(type, examIndex); } else { console.warn('[BrowseController] filterByType 函数未定义'); } @@ -11617,7 +13049,7 @@ * 按文件夹筛选(频率模式) * @param {string} filterId - 筛选器ID */ - filterByFolder(filterId) { + filterByFolder(filterId, examIndex = []) { const config = this.getCurrentModeConfig(); const basePath = global.__browsePath || config.basePath || null; const folders = config.folderMap[filterId]; @@ -11629,11 +13061,8 @@ return; } - // 获取题库索引 - const examIndex = this.getExamIndex(); - // 筛选题目 - const filtered = examIndex.filter(exam => { + const filtered = (Array.isArray(examIndex) ? examIndex : []).filter(exam => { if (!exam || !exam.path) { return false; } @@ -11656,23 +13085,6 @@ // 显示筛选结果 this.displayFilteredExams(filtered); } - - - - /** - * 获取题库索引 - * @returns {Array} 题库数组 - */ - getExamIndex() { - // 优先使用全局状态服务 - if (typeof global.getExamIndexState === 'function') { - return global.getExamIndexState(); - } - - // 回退到全局变量 - return Array.isArray(global.examIndex) ? global.examIndex : []; - } - /** * 显示筛选后的题目 * @param {Array} exams - 题目数组 @@ -11710,11 +13122,11 @@ /** * 从全局状态恢复模式 */ - restoreMode() { + restoreMode(examIndex = []) { try { const savedMode = global.__browseFilterMode; if (savedMode && BROWSE_MODES[savedMode]) { - this.currentMode = isListeningMode(savedMode) && !hasActiveListeningLibrary() + this.currentMode = isListeningMode(savedMode) && !hasActiveListeningLibrary(examIndex) ? 'default' : savedMode; } @@ -11726,8 +13138,8 @@ /** * 重置为默认模式 */ - resetToDefault() { - this.setMode('default'); + resetToDefault(examIndex = []) { + this.setMode('default', examIndex); } // ============================================================================ @@ -11850,10 +13262,8 @@ // 4. 调用 ExamActions.loadExamList 来执行真正的筛选和渲染 // 这确保了所有逻辑(包括频率模式、置顶等)都由 ExamActions 统一处理 - if (global.ExamActions && typeof global.ExamActions.loadExamList === 'function') { - global.ExamActions.loadExamList(); - } else if (typeof global.loadExamList === 'function') { - global.loadExamList(); + if (typeof global.loadExamList === 'function') { + global.loadExamList(normalizedOptions.examIndex || null); } else { console.warn('[BrowseController] 无法加载题库列表: loadExamList 未定义'); } @@ -11878,9 +13288,8 @@ global.BrowseController = BrowseController; global.BROWSE_MODES = BROWSE_MODES; global.refreshListeningAvailabilityUI = function refreshListeningAvailabilityUI(index) { - const listeningAvailable = Array.isArray(index) - ? hasListeningEntries(index) - : hasActiveListeningLibrary(); + const examIndex = Array.isArray(index) ? index : []; + const listeningAvailable = hasListeningEntries(examIndex); const controller = global.browseController || null; if (controller && isListeningMode(controller.currentMode) && !listeningAvailable) { @@ -11901,7 +13310,7 @@ } if (controller && controller.buttonContainer) { - controller.renderFilterButtons(); + controller.renderFilterButtons(examIndex); } else { const container = global.document && global.document.getElementById('type-filter-buttons'); const listeningButtons = container @@ -12370,15 +13779,9 @@ class BrowseStateManager { */ initialize() { console.log('[BrowseStateManager] 初始化浏览状态管理器'); - - // 恢复保存的状态 - this.restorePersistentState(); - // 设置事件监听器 this.setupEventListeners(); - - // 初始化完成后通知订阅者 - this.notifySubscribers(); + this.ready = this.restorePersistentState().finally(() => this.notifySubscribers()); } /** @@ -12533,7 +13936,7 @@ class BrowseStateManager { /** * 持久化状态 */ - persistState() { + async persistState() { try { const dataToSave = { currentFilter: this.currentFilter, @@ -12543,7 +13946,7 @@ class BrowseStateManager { timestamp: Date.now() }; - localStorage.setItem('browse_state', JSON.stringify(dataToSave)); + await window.AppData.preferences.patchBrowse({ stateManager: dataToSave }); console.log('[BrowseStateManager] 状态已持久化'); } catch (error) { console.error('[BrowseStateManager] 持久化状态失败:', error); @@ -12553,11 +13956,13 @@ class BrowseStateManager { /** * 恢复持久化的状态 */ - restorePersistentState() { + async restorePersistentState() { try { - const savedData = localStorage.getItem('browse_state'); + await window.AppData.ready; + const browse = await window.AppData.preferences.getBrowse(); + const savedData = browse && browse.stateManager; if (savedData) { - const data = JSON.parse(savedData); + const data = savedData; // 恢复基本状态 this.previousFilter = data.previousFilter || null; @@ -13183,7 +14588,20 @@ window.BrowseStateManager = BrowseStateManager; function compareAnswers(userAnswer, correctAnswer) { const expected = splitAnswerTokens(correctAnswer); - const actual = splitAnswerTokens(userAnswer); + let actual = splitAnswerTokens(userAnswer); + + if ( + expected.length === 1 + && /^[A-Z]$/.test(expected[0]) + && actual.length === 1 + && !/^[A-Z]$/.test(actual[0]) + && typeof userAnswer === 'string' + ) { + const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/); + if (labeledOption) { + actual = [labeledOption[1]]; + } + } if (expected.length === 0 && actual.length === 0) { return null; @@ -13840,158 +15258,19 @@ window.BrowseStateManager = BrowseStateManager; }; } - function getAllExamIndexes(globalObj) { - let readingIndex = null; - if (globalObj && typeof globalObj.getReadingExamIndex === 'function') { - try { - readingIndex = globalObj.getReadingExamIndex(); - } catch (_) { - readingIndex = null; - } - } - const sources = [ - readingIndex, - globalObj.__READING_EXAM_INDEX__, - globalObj.examIndex, - globalObj.readingExamIndex, - globalObj.listeningExamIndex, - globalObj.fullExamIndex, - globalObj.practiceExamIndex - ]; - return sources - .filter(Array.isArray) - .reduce((acc, list) => acc.concat(list), []); - } - - function normalizeTitle(title) { - return toStringKey(title) - .toLowerCase() - .replace(/[\s\-_\u3000]+/g, '') - .replace(/[^\w\u4e00-\u9fa5]/g, ''); - } - - function findExamEntry(record, metadata, globalObj) { - const indexes = getAllExamIndexes(globalObj); - if (indexes.length === 0) { - return null; - } - - const candidateIds = [ - record && record.examId, - record && record.originalExamId, - record && record.derivedExamId, - record && record.realData && record.realData.examId, - metadata && metadata.examId, - metadata && metadata.id - ] - .map(toStringKey) - .filter(Boolean); - - // 1. 精确 ID 匹配 - if (candidateIds.length > 0) { - const idLookup = new Map(); - indexes.forEach(item => { - if (!item || typeof item !== 'object') { - return; - } - const itemId = toStringKey(item.id); - if (itemId) { - idLookup.set(itemId.toLowerCase(), item); - } - }); - - for (const id of candidateIds) { - const normalizedId = id.toLowerCase(); - if (idLookup.has(normalizedId)) { - return idLookup.get(normalizedId); - } - } - } - - // 2. 通过 URL 路径匹配(针对全量题库) - if (record && record.url) { - const urlPath = record.url.toLowerCase(); - const match = indexes.find(item => { - if (!item || !item.path) return false; - const itemPath = item.path.toLowerCase(); - // 提取 URL 中的文件夹名称 - const urlParts = urlPath.split('/').filter(Boolean); - const pathParts = itemPath.split('/').filter(Boolean); - - // 检查是否有共同的文件夹路径 - for (let i = 0; i < Math.min(urlParts.length, pathParts.length); i++) { - if (urlParts[urlParts.length - 1 - i] === pathParts[pathParts.length - 1 - i]) { - return true; - } - } - return false; - }); - if (match) { - console.log('[AnswerComparisonUtils] 通过 URL 路径匹配到题目:', match.id, match.title); - return match; - } + function inferCategory(record, metadata, examEntry) { + if (metadata && metadata.category && metadata.category !== 'Unknown') { + return metadata.category; } - // 3. 精确标题匹配 - const candidateTitles = [ - metadata && metadata.examTitle, - metadata && metadata.title, - record && record.title, - record && record.examTitle, - record && record.realData && record.realData.title - ] - .map(normalizeTitle) - .filter(Boolean); - - if (candidateTitles.length > 0) { - const titleLookup = new Map(); - indexes.forEach(item => { - if (!item || typeof item !== 'object') { - return; - } - const itemTitle = normalizeTitle(item.title); - if (itemTitle) { - titleLookup.set(itemTitle, item); - } - }); - - for (const title of candidateTitles) { - if (titleLookup.has(title)) { - return titleLookup.get(title); - } - } - - // 4. 模糊标题匹配(移除标签前缀后比较) - for (const candidateTitle of candidateTitles) { - const match = indexes.find(item => { - if (!item || !item.title) return false; - const itemTitle = normalizeTitle(item.title); - // 移除标签前缀,如 "[听力全量-...] City Development" vs "City Development" - const cleanCandidate = candidateTitle.replace(/^\[.*?\]\s*/, ''); - const cleanItem = itemTitle.replace(/^\[.*?\]\s*/, ''); - return cleanCandidate === cleanItem || - (cleanCandidate.length > 5 && cleanItem.includes(cleanCandidate)) || - (cleanItem.length > 5 && cleanCandidate.includes(cleanItem)); - }); - if (match) { - console.log('[AnswerComparisonUtils] 通过模糊标题匹配到题目:', match.id, match.title); - return match; - } - } + if (record && record.category && record.category !== 'Unknown') { + return record.category; } - return null; - } - - function inferCategory(record, metadata, examEntry) { if (examEntry && examEntry.category) { return examEntry.category; } - if (metadata && metadata.category && metadata.category !== 'Unknown') { - return metadata.category; - } - const candidates = [ record && record.examId, metadata && metadata.examId, @@ -14016,7 +15295,7 @@ window.BrowseStateManager = BrowseStateManager; return metadata && metadata.category ? metadata.category : 'Unknown'; } - function enrichRecordMetadata(record) { + function enrichRecordMetadata(record, examEntry = null) { if (!record || typeof record !== 'object') { return { category: 'Unknown', @@ -14032,25 +15311,24 @@ window.BrowseStateManager = BrowseStateManager; return metadata; } - const globalObj = global || {}; - const examEntry = findExamEntry(record, metadata, globalObj); + const resolvedExam = examEntry && typeof examEntry === 'object' ? examEntry : null; - if (examEntry) { - if (examEntry.title && !metadata.examTitle) { - metadata.examTitle = examEntry.title; + if (resolvedExam) { + if (resolvedExam.title && !metadata.examTitle) { + metadata.examTitle = resolvedExam.title; } - if (examEntry.frequency && !metadata.frequency) { - metadata.frequency = examEntry.frequency; + if (resolvedExam.frequency && !metadata.frequency) { + metadata.frequency = resolvedExam.frequency; } - if (examEntry.type && !metadata.type) { - metadata.type = examEntry.type; + if (resolvedExam.type && !metadata.type) { + metadata.type = resolvedExam.type; } } - metadata.category = inferCategory(record, metadata, examEntry); + metadata.category = inferCategory(record, metadata, resolvedExam); if (!metadata.frequency) { - if (examEntry && examEntry.frequency) { - metadata.frequency = examEntry.frequency; + if (resolvedExam && resolvedExam.frequency) { + metadata.frequency = resolvedExam.frequency; } else if (metadata.frequency == null) { metadata.frequency = 'unknown'; } @@ -14079,13 +15357,13 @@ window.BrowseStateManager = BrowseStateManager; return metadata; } - function withEnrichedMetadata(record) { + function withEnrichedMetadata(record, examEntry = null) { if (!record || typeof record !== 'object') { return record; } const clone = Object.assign({}, record); clone.metadata = Object.assign({}, record.metadata || {}); - enrichRecordMetadata(clone); + enrichRecordMetadata(clone, examEntry); return clone; } @@ -14112,8 +15390,10 @@ window.BrowseStateManager = BrowseStateManager; (function (global) { 'use strict'; - const BROWSE_VIEW_PREFERENCE_KEY = 'browse_view_preferences_v2'; let browsePreferencesCache = null; + let browsePreferencesReady = null; + let browsePreferenceWriteQueue = Promise.resolve(); + const pendingBrowsePreferenceWrites = []; let currentBrowseScrollElement = null; let removeBrowseScrollListener = null; let pendingBrowseAutoScroll = null; @@ -14246,14 +15526,20 @@ window.BrowseStateManager = BrowseStateManager; } function loadBrowsePreferencesFromStorage() { + if (!browsePreferencesReady) { + browsePreferencesReady = Promise.resolve().then(async () => { + if (!global.AppData || !global.AppData.preferences) return; + await global.AppData.ready; + const parsed = await global.AppData.preferences.getBrowse(); + const defaults = getDefaultBrowsePreferences(); + const next = Object.assign({}, defaults, parsed || {}); + if (!next.scrollPositions || typeof next.scrollPositions !== 'object') next.scrollPositions = {}; + next.listAnchors = mergeBrowseAnchors({}, next.listAnchors); + browsePreferencesCache = next; + }).catch((error) => console.warn('[BrowsePreferences] 无法读取浏览偏好,使用默认值', error)); + } try { - const raw = localStorage.getItem(BROWSE_VIEW_PREFERENCE_KEY); - if (!raw) { - return getDefaultBrowsePreferences(); - } - const parsed = JSON.parse(raw); - const defaults = getDefaultBrowsePreferences(); - const next = Object.assign({}, defaults, parsed || {}); + const next = Object.assign({}, getDefaultBrowsePreferences(), browsePreferencesCache || {}); if (!next.scrollPositions || typeof next.scrollPositions !== 'object') { next.scrollPositions = {}; } @@ -14272,9 +15558,16 @@ window.BrowseStateManager = BrowseStateManager; return browsePreferencesCache; } - function saveBrowseViewPreferences(partial = {}) { - const current = getBrowseViewPreferences(); - const next = { + async function whenBrowseViewPreferencesReady() { + loadBrowsePreferencesFromStorage(); + if (browsePreferencesReady) { + await browsePreferencesReady; + } + return getBrowseViewPreferences(); + } + + function mergeBrowsePreferences(current, partial = {}) { + return { scrollPositions: Object.assign({}, current.scrollPositions, partial.scrollPositions || {}), listAnchors: mergeBrowseAnchors(current.listAnchors, partial.listAnchors), autoScrollEnabled: Object.prototype.hasOwnProperty.call(partial, 'autoScrollEnabled') @@ -14284,15 +15577,39 @@ window.BrowseStateManager = BrowseStateManager; ? (partial.lastFilter || null) : current.lastFilter }; + } - try { - localStorage.setItem(BROWSE_VIEW_PREFERENCE_KEY, JSON.stringify(next)); - browsePreferencesCache = next; - } catch (error) { - console.warn('[BrowsePreferences] 保存浏览偏好失败', error); - browsePreferencesCache = next; + function saveBrowseViewPreferences(partial = {}) { + const request = { partial: Object.assign({}, partial) }; + pendingBrowsePreferenceWrites.push(request); + const preview = pendingBrowsePreferenceWrites.reduce( + (current, pending) => mergeBrowsePreferences(current, pending.partial), + getBrowseViewPreferences() + ); + + if (!global.AppData || !global.AppData.preferences) { + pendingBrowsePreferenceWrites.splice(pendingBrowsePreferenceWrites.indexOf(request), 1); + console.warn('[BrowsePreferences] AppData.preferences 不可用,偏好未保存'); + return preview; } - return browsePreferencesCache; + + browsePreferenceWriteQueue = browsePreferenceWriteQueue.then(async () => { + await global.AppData.ready; + if (browsePreferencesReady) await browsePreferencesReady; + const next = mergeBrowsePreferences(getBrowseViewPreferences(), request.partial); + await global.AppData.preferences.patchBrowse(next); + browsePreferencesCache = next; + }).catch((error) => { + console.warn('[BrowsePreferences] 保存浏览偏好失败,保留上次已提交值', error); + }).finally(() => { + const index = pendingBrowsePreferenceWrites.indexOf(request); + if (index >= 0) pendingBrowsePreferenceWrites.splice(index, 1); + }); + return preview; + } + + function flushBrowsePreferenceWrites() { + return browsePreferenceWriteQueue.then(() => getBrowseViewPreferences()); } function persistBrowseFilter(category, type) { @@ -14534,20 +15851,20 @@ window.BrowseStateManager = BrowseStateManager; }; } - function findLastPracticeExamEntry(exams, category, type) { + function findLastPracticeExamEntry(exams, records, examIndex, category, type) { const normalizedCategory = normalizeCategoryKey(category); const normalizedType = normalizeExamType(type); - const records = global.getPracticeRecordsState ? global.getPracticeRecordsState() : []; - if (!Array.isArray(records) || records.length === 0) { + const recordSnapshot = Array.isArray(records) ? records : []; + if (recordSnapshot.length === 0) { return null; } - const examIndex = global.getExamIndexState ? global.getExamIndexState() : []; + const indexSnapshot = Array.isArray(examIndex) ? examIndex : []; let latest = null; let latestTimestamp = Number.NEGATIVE_INFINITY; - records.forEach((record) => { - const info = resolveRecordExamInfo(record, examIndex); + recordSnapshot.forEach((record) => { + const info = resolveRecordExamInfo(record, indexSnapshot); if (!info) { return; } @@ -14699,7 +16016,7 @@ window.BrowseStateManager = BrowseStateManager; return parts.join(' '); } - function setupBrowsePreferenceUI() { + async function setupBrowsePreferenceUI() { const trigger = document.getElementById('browse-title-trigger'); const panel = document.getElementById('browse-preference-panel'); const checkbox = document.getElementById('browse-remember-position'); @@ -14708,7 +16025,7 @@ window.BrowseStateManager = BrowseStateManager; return; } - const prefs = getBrowseViewPreferences(); + const prefs = await whenBrowseViewPreferencesReady(); checkbox.checked = !!prefs.autoScrollEnabled; updateBrowsePreferenceIndicator(prefs.autoScrollEnabled); @@ -14769,18 +16086,18 @@ window.BrowseStateManager = BrowseStateManager; }); } - function handlePostExamListRender(exams, { category, type } = {}) { + async function handlePostExamListRender(exams, { category, type } = {}) { const scrollEl = document.querySelector('#exam-list-container .exam-list'); if (!scrollEl) { return; } + const prefs = await whenBrowseViewPreferencesReady(); ensureBrowseScrollListener(scrollEl); const normalizedCategory = normalizeCategoryKey(category || (global.getCurrentCategory ? global.getCurrentCategory() : 'all')); const normalizedType = normalizeExamType(type || (global.getCurrentExamType ? global.getCurrentExamType() : 'all')); const autoScrollContext = consumeBrowseAutoScroll(normalizedCategory, normalizedType); - const prefs = getBrowseViewPreferences(); const applyScroll = () => { const performFallback = () => { @@ -14806,17 +16123,12 @@ window.BrowseStateManager = BrowseStateManager; }; if (prefs.autoScrollEnabled && (normalizedCategory !== 'all' || normalizedType !== 'all')) { - const entry = findLastPracticeExamEntry(exams, normalizedCategory, normalizedType); - if (entry) { - const retries = autoScrollContext ? 7 : 4; - attemptScrollToEntry(entry, retries, performFallback); - return; - } const anchor = getBrowseListAnchor(normalizedCategory, normalizedType); if (anchor) { const entryFromAnchor = findExamEntryByAnchor(exams, anchor); if (entryFromAnchor) { - attemptScrollToEntry(entryFromAnchor, 3, performFallback); + const retries = autoScrollContext ? 7 : 4; + attemptScrollToEntry(entryFromAnchor, retries, performFallback); return; } } @@ -14832,14 +16144,14 @@ window.BrowseStateManager = BrowseStateManager; } } - function updateBrowseAnchorsFromRecords(records) { + function updateBrowseAnchorsFromRecords(records, examIndex) { const list = Array.isArray(records) ? records : []; - const examIndex = global.getExamIndexState ? global.getExamIndexState() : []; + const indexSnapshot = Array.isArray(examIndex) ? examIndex : []; const updates = {}; const seenKeys = new Set(); list.forEach((record) => { - const info = resolveRecordExamInfo(record, examIndex); + const info = resolveRecordExamInfo(record, indexSnapshot); if (!info) { return; } @@ -14895,7 +16207,9 @@ window.BrowseStateManager = BrowseStateManager; global.normalizeExamType = normalizeExamType; global.buildBrowseFilterKey = buildBrowseFilterKey; global.getBrowseViewPreferences = getBrowseViewPreferences; + global.whenBrowseViewPreferencesReady = whenBrowseViewPreferencesReady; global.saveBrowseViewPreferences = saveBrowseViewPreferences; + global.flushBrowsePreferenceWrites = flushBrowsePreferenceWrites; global.persistBrowseFilter = persistBrowseFilter; global.getPersistedBrowseFilter = getPersistedBrowseFilter; global.updateBrowseAnchorsFromRecords = updateBrowseAnchorsFromRecords; @@ -15141,7 +16455,11 @@ function ensureLegacyNavigation(options) { syncOnNavigate: true, onRepeatNavigate: function onRepeatNavigate(viewName) { if (viewName === 'browse') { - resetBrowseViewToAll(); + if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') { + window.ExamActions.resetBrowseViewToAll(); + } else if (typeof window.resetBrowseViewToAll === 'function') { + window.resetBrowseViewToAll(); + } } }, onNavigate: function onNavigate(viewName) { @@ -15195,13 +16513,6 @@ async function initializeLegacyComponents() { browseStateManager = new BrowseStateManager(); console.log('[System] 浏览状态管理器已初始化'); } - if (window.DataIntegrityManager) { - window.dataIntegrityManager = new DataIntegrityManager(); - console.log('[System] 数据完整性管理器已初始化'); - } else { - console.info('[System] DataIntegrityManager 按需加载,跳过启动初始化'); - } - // 性能优化器已拆到 diagnostics-tools;浏览页保留无依赖降级路径。 if (window.PerformanceOptimizer) { window.performanceOptimizer = new PerformanceOptimizer(); @@ -15210,93 +16521,47 @@ async function initializeLegacyComponents() { console.info('[System] PerformanceOptimizer 按需加载,跳过启动初始化'); } - // Clean up old cache and configurations for v1.1.0 upgrade (one-time only) - let needsCleanup = false; - try { - needsCleanup = !localStorage.getItem('upgrade_v1_1_0_cleanup_done'); - } catch (error) { - console.warn('[System] 检查升级标记失败,将继续执行清理流程', error); - needsCleanup = true; - } - - if (needsCleanup) { - console.log('[System] 首次运行,执行升级清理...'); - try { - await cleanupOldCache(); - } finally { - try { localStorage.setItem('upgrade_v1_1_0_cleanup_done', '1'); } catch (_) { } - } - } else { - console.log('[System] 升级清理已完成,跳过重复清理'); - } - // Load data and setup listeners await loadLibraryInternal(); - startPracticeRecordsSyncInBackground('boot'); // 后台静默加载练习记录,避免阻塞首页 + // 首页/题库浏览只使用摘要记录;完整 answers/realData 在进入练习历史页时再加载。 setupMessageListener(); // Listen for updates from child windows - setupStorageSyncListener(); // Listen for storage changes from other tabs -} - -// Clean up old cache and configurations -async function cleanupOldCache() { - try { - console.log('[System] 正在清理旧缓存与配置...'); - await storage.remove('exam_index'); - await storage.remove('active_exam_index_key'); - await storage.set('exam_index_configurations', []); - console.log('[System] 旧缓存清理完成'); - } catch (error) { - console.warn('[System] 清理旧缓存时出错:', error); - } } - // --- Data Loading and Management --- -// Phase 3: 练习记录同步 - 保留在 main.js(核心数据流,暂不迁移) +// Practice history is read from AppData for each refresh. Only its signature is +// retained as runtime UI state; record arrays never become a second authority. +let lastPracticeRecordsSignature = null; async function syncPracticeRecords(options = {}) { - const { forceRender = false } = options || {}; - console.log('[System] 正在从存储中同步练习记录...'); - const previousRecords = typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []); - let records = []; - let loadError = null; - try { - records = await listCanonicalPracticeRecords(); - } catch (e) { - console.warn('[System] 同步记录时发生错误:', e); - loadError = e; - records = Array.isArray(previousRecords) ? previousRecords.slice() : []; - const errorMessage = String(e && e.message ? e.message : e).toLowerCase(); - if (errorMessage.includes('not ready') || errorMessage.includes('未就绪')) { - setTimeout(() => { - try { - startPracticeRecordsSyncInBackground('api-ready-retry'); - } catch (_) { } - }, 800); - } - if (Array.isArray(previousRecords) && previousRecords.length > 0) { - console.warn('[System] canonical store 暂未就绪,保留当前内存中的练习记录,避免误清空视图。'); - } - } - - if (loadError && (!Array.isArray(records) || records.length === 0) && (!Array.isArray(previousRecords) || previousRecords.length === 0)) { - console.warn('[System] canonical store 暂未就绪,本次跳过练习记录视图刷新。'); - return; - } - - // Normalize duration and percentages to avoid 0-second artifacts + const { forceRender = false, mode = 'summary' } = options || {}; + const loadMode = mode === 'full' ? 'full' : 'summary'; + let recordsUnchanged = false; + console.log(`[System] 正在从存储中同步练习记录... (mode=${loadMode})`); + let [records, insightRecords, examIndex] = await Promise.all([ + listCanonicalPracticeRecordSummaries(), + window.AppData.practice.listInsights({ limit: 10 }), + resolveActiveExamIndex() + ]); + const insightsById = new Map((Array.isArray(insightRecords) ? insightRecords : []) + .filter((record) => record && record.id) + .map((record) => [String(record.id), record])); + records = (Array.isArray(records) ? records : []).map((record) => + record && insightsById.has(String(record.id)) + ? Object.assign({}, record, insightsById.get(String(record.id))) + : record); + if (loadMode === 'full') { + console.log('[System] mode=full 请求已限定为 light 视图刷新;完整记录请直接调用 AppData.practice.list()'); + } + + // Normalize duration and percentages to avoid 0-second artifacts(summary 无 realData/interactions) try { records = (records || []).map(r => { - const rd = (r && r.realData) || {}; let duration = (typeof r.duration === 'number') ? r.duration : undefined; if (!(Number.isFinite(duration) && duration > 0)) { - const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {}; + const sInfo = r && r.scoreInfo || {}; const candidates = [ - r.duration, rd.duration, r.durationSeconds, r.duration_seconds, + r.duration, r.durationSeconds, r.duration_seconds, r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent, - rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent, sInfo.duration, sInfo.timeSpent ]; for (const v of candidates) { @@ -15310,22 +16575,12 @@ async function syncPracticeRecords(options = {}) { duration = Math.round((e - s) / 1000); } } - if (!(Number.isFinite(duration) && duration > 0) && rd && Array.isArray(rd.interactions) && rd.interactions.length) { - try { - const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n)); - if (ts.length) { - const span = Math.max(...ts) - Math.min(...ts); - if (Number.isFinite(span) && span > 0) duration = Math.floor(span / 1000); - } - } catch (_) { } - } } if (!Number.isFinite(duration)) duration = 0; - // Coerce percentage/accuracy if only scoreInfo exists - const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {}; + const sInfo = r && r.scoreInfo || {}; const correct = (typeof r.correctAnswers === 'number') ? r.correctAnswers : (typeof sInfo.correct === 'number' ? sInfo.correct : (typeof r.score === 'number' ? r.score : undefined)); - const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : (rd.answers ? Object.keys(rd.answers).length : undefined)); + const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : undefined); let accuracy = (typeof r.accuracy === 'number') ? r.accuracy : undefined; let percentage = (typeof r.percentage === 'number') ? r.percentage : undefined; if ((accuracy === undefined || percentage === undefined) && Number.isFinite(correct) && Number.isFinite(total) && total > 0) { @@ -15338,138 +16593,71 @@ async function syncPracticeRecords(options = {}) { }); } catch (e) { console.warn('[System] normalize durations failed:', e); } - // 若数据未变则跳过 UI 刷新,避免无意义的列表重置 - // 使用轻量 listSummary 进行签名比对,无需反序列化+克隆完整记录数组 + // Avoid resetting the list when the authoritative light projection is unchanged. try { - const prev = typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []); const renderer = window.PracticeHistoryRenderer; if (renderer && renderer.helpers && typeof renderer.helpers.computeRecordsSignature === 'function') { - const prevSig = renderer.helpers.computeRecordsSignature(prev); - // 若 forceRender 则跳过轻量查询,直接走完整加载 - if (!forceRender && window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') { - const summaries = await window.PracticeRecordAPI.listSummary(); - const nextSig = renderer.helpers.computeRecordsSignature(summaries); - if (prevSig === nextSig) { - console.log('[System] 练习记录未变化,跳过UI刷新'); - return; - } - } else { - const nextSig = renderer.helpers.computeRecordsSignature(records); - if (!forceRender && prevSig === nextSig) { - console.log('[System] 练习记录未变化,跳过UI刷新'); - return; - } + const nextSignature = renderer.helpers.computeRecordsSignature(records); + if (!forceRender && lastPracticeRecordsSignature === nextSignature) { + console.log('[System] 练习记录未变化,跳过UI刷新'); + recordsUnchanged = true; } + lastPracticeRecordsSignature = nextSignature; } } catch (_) { /* 保底不中断同步流程 */ } - // 新增修复3D:确保全局变量和 app.state 都跟 canonical records 保持一致 - setPracticeRecordsState(records); - try { - if (window.app && window.app.state && window.app.state.practice) { - const nextRecords = typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(records) ? records : []); - window.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : []; - } - } catch (error) { - console.warn('[System] 同步练习记录到 App state 失败:', error); - } - refreshBrowseProgressFromRecords(records); + refreshBrowseProgressFromRecords(records, examIndex); - console.log(`[System] ${records.length} 条练习记录已加载到内存。`); - updatePracticeView(); + console.log(`[System] 已从 AppData 加载 ${records.length} 条练习摘要。`); + if (!recordsUnchanged) { + updatePracticeView(records, examIndex); + } + return records; } let practiceRecordsLoadPromise = null; -function ensurePracticeRecordsSync(trigger = 'default') { +function ensurePracticeRecordsSync(trigger = 'default', options = {}) { if (practiceRecordsLoadPromise) { return practiceRecordsLoadPromise; } const loadTask = (async () => { - await syncPracticeRecords(); - return true; - })().catch((error) => { - console.warn(`[System] 练习记录同步失败(${trigger}):`, error); - return false; - }); + return syncPracticeRecords(Object.assign({ mode: 'summary' }, options || {})); + })(); practiceRecordsLoadPromise = loadTask.finally(() => { practiceRecordsLoadPromise = null; }); return practiceRecordsLoadPromise; } -function startPracticeRecordsSyncInBackground(trigger = 'default') { - try { - ensurePracticeRecordsSync(trigger); - } catch (error) { +function startPracticeRecordsSyncInBackground(trigger = 'default', options = {}) { + ensurePracticeRecordsSync(trigger, options).catch((error) => { console.warn(`[System] 后台同步练习记录失败(${trigger}):`, error); - } + }); } async function listCanonicalPracticeRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } - - throw new Error('统一练习记录 API 未就绪'); + // 两个调用方(bulkDeleteRecords / deleteRecord)只用 id、title、date 做存在性校验与确认文案, + // light 投影已覆盖;删除本身走 AppData.practice.delete/deleteMany,不需要全量答题详情。 + const records = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(records) ? records : []; } -async function replaceCanonicalPracticeRecords(records) { - const finalRecords = Array.isArray(records) ? records : []; - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') { - await window.PracticeRecordAPI.replace(finalRecords, { - maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000 - }); - return true; - } - - throw new Error('统一练习记录 API 未就绪'); +async function listCanonicalPracticeRecordSummaries() { + const summaries = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(summaries) ? summaries : []; } -function cleanupLegacyPracticeRecordArtifacts() { - // Unprefixed legacy keys only — never the active backend key. - const legacyRawKeys = ['practice_records', 'old_prefix_practice_records']; - - try { - legacyRawKeys.forEach((key) => { - try { localStorage.removeItem(key); } catch (_) { } - try { sessionStorage.removeItem(key); } catch (_) { } - }); - } catch (error) { - console.warn('[System] 清理 legacy 练习记录影子键失败:', error); - } - - const storage = window.storage; - const shadowKey = storage && typeof storage.getKey === 'function' - ? storage.getKey('practice_records') - : null; - if (!shadowKey) { - return; +async function resolveActiveExamIndex() { + if (typeof window.resolveActiveLibraryIndex === 'function') { + const index = await window.resolveActiveLibraryIndex(); + return Array.isArray(index) ? index : []; } - - // When IndexedDB is blocked/unavailable, writePersistentValue stores canonical - // practice_records under exam_system_practice_records in localStorage/sessionStorage. - // Removing that key after replace/delete would wipe the just-persisted history. - const mode = storage && storage.mode; - const usesWebStorageBackend = mode === 'localStorage' || mode === 'sessionStorage'; - if (usesWebStorageBackend || storage.indexedDBBlocked || !storage.indexedDB) { - return; + const manager = await ensureLibraryManagerReady(); + if (manager && typeof manager.resolveActiveIndex === 'function') { + const index = await manager.resolveActiveIndex(); + return Array.isArray(index) ? index : []; } - - try { localStorage.removeItem(shadowKey); } catch (_) { } - try { sessionStorage.removeItem(shadowKey); } catch (_) { } -} - -async function persistPracticeRecordsAndRefresh(records, trigger = 'manual-update') { - const finalRecords = Array.isArray(records) ? records : []; - await replaceCanonicalPracticeRecords(finalRecords); - cleanupLegacyPracticeRecordArtifacts(); - await syncPracticeRecords({ forceRender: true }); - return getPracticeRecordsState(); + throw new Error('LibraryManager.resolveActiveIndex is unavailable'); } const completionNoticeState = { @@ -15532,6 +16720,30 @@ function extractCompletionSessionId(envelope) { return null; } +// fallbackExamSessions 是纯内存 Map(js/app.js:50),主页刷新后会话映射即丢失。 +// 完成消息本身携带 examId(unifiedReadingPage.buildEnvelope / practicePageEnhancer.buildResultsPayload / +// listeningRecordBridge.buildBridgePayload 都会写入),据此仍可走同一条持久化路径。 +function resolveCompletionExamId(envelope, payload) { + const sources = [payload, envelope, envelope && envelope.data]; + for (const source of sources) { + if (!source || typeof source !== 'object') { + continue; + } + const candidates = [ + source.examId, + source.derivedExamId, + source.metadata && typeof source.metadata === 'object' ? source.metadata.examId : null + ]; + for (const candidate of candidates) { + const normalized = candidate == null ? '' : String(candidate).trim(); + if (normalized) { + return normalized; + } + } + } + return null; +} + function shouldAnnounceCompletion(sessionId) { const now = Date.now(); if (sessionId && completionNoticeState.lastSessionId === sessionId) { @@ -15663,6 +16875,17 @@ if (typeof window !== 'undefined') { } function setupMessageListener() { + const resolveFallbackMessageOrigin = () => { + const location = window.location || {}; + const rawOrigin = typeof location.origin === 'string' ? location.origin : ''; + const isOpaqueFile = location.protocol === 'file:' + || rawOrigin === 'null' + || rawOrigin === 'file://' + || rawOrigin.startsWith('file:'); + return isOpaqueFile + ? { declaredOrigin: 'null', targetOrigin: '*' } + : { declaredOrigin: rawOrigin, targetOrigin: rawOrigin }; + }; const findFallbackSessionByWindow = (sourceWindow) => { if (!sourceWindow || !window.fallbackExamSessions || typeof fallbackExamSessions.entries !== 'function') { return null; @@ -15681,30 +16904,123 @@ function setupMessageListener() { if (!entry || !entry.rec || !entry.rec.win || entry.rec.win.closed) { return; } - const payload = entry.rec.initPayload || { + const messageOrigin = resolveFallbackMessageOrigin(); + const targetOrigin = messageOrigin.targetOrigin; + if (!targetOrigin) return; + if (!entry.rec.windowSessionToken) { + const cryptoApi = window.crypto; + if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return; + const bytes = new Uint8Array(24); + cryptoApi.getRandomValues(bytes); + entry.rec.windowSessionToken = Array.from(bytes) + .map(byte => byte.toString(16).padStart(2, '0')) + .join(''); + } + const payload = Object.assign({}, entry.rec.initPayload || { examId: entry.rec.examId, - parentOrigin: window.location.origin, + parentOrigin: messageOrigin.declaredOrigin, sessionId: entry.rec.sessionId || entry.sid - }; + }, { + parentOrigin: messageOrigin.declaredOrigin, + windowSessionToken: entry.rec.windowSessionToken + }); + entry.rec.initPayload = payload; try { - entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload }, '*'); - entry.rec.win.postMessage({ type: 'init_exam_session', data: payload }, '*'); + entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload, source: 'exam_host' }, targetOrigin); + entry.rec.win.postMessage({ type: 'init_exam_session', data: payload, source: 'exam_host' }, targetOrigin); } catch (_) { } }; - window.addEventListener('message', (event) => { - // 更兼容的安全检查:允许同源或file协议下的子窗口 + const sendFallbackSubmitOutcome = (rec, payload, succeeded, errorCode = '') => { + const submissionId = payload && payload.submissionId != null ? String(payload.submissionId).trim() : ''; + const sessionId = payload && payload.sessionId != null ? String(payload.sessionId).trim() : ''; + if (!rec || !rec.win || rec.win.closed || !submissionId || !sessionId) return false; + const targetOrigin = resolveFallbackMessageOrigin().targetOrigin; + if (!targetOrigin || !rec.windowSessionToken) return false; try { - if (event.origin && event.origin !== 'null' && event.origin !== window.location.origin) { - return; - } - } catch (_) { } + rec.win.postMessage({ + type: succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED', + data: { + examId: payload.examId || rec.examId || null, + sessionId, + suiteSessionId: payload.suiteSessionId || null, + submissionId, + errorCode: succeeded ? null : (errorCode || 'save_failed'), + windowSessionToken: rec.windowSessionToken + }, + source: 'exam_host', + timestamp: Date.now() + }, targetOrigin); + return true; + } catch (_) { + return false; + } + }; + + const sendFallbackVocabOutcome = (rec, payload, succeeded, errorCode = '') => { + const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : ''; + const sessionId = payload && payload.sessionId != null + ? String(payload.sessionId).trim() + : String(rec && rec.sessionId || ''); + if (!rec || !rec.win || rec.win.closed || !requestId || !sessionId || !rec.windowSessionToken) return false; + const targetOrigin = resolveFallbackMessageOrigin().targetOrigin; + if (!targetOrigin) return false; + try { + rec.win.postMessage({ + type: succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED', + data: { + requestId, + examId: payload.examId || rec.examId || null, + sessionId, + errorCode: succeeded ? null : (errorCode || 'save_failed'), + windowSessionToken: rec.windowSessionToken + }, + source: 'exam_host', + timestamp: Date.now() + }, targetOrigin); + return true; + } catch (_) { + return false; + } + }; + + const verifyFallbackPracticeCompletionRecord = async (record) => { + if (!record || typeof record !== 'object' || !record.id || !record.examId || !record.sessionId) { + return null; + } + if (!window.AppData || !window.AppData.practice || typeof window.AppData.practice.get !== 'function') { + return null; + } + const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' }); + if (!persisted || typeof persisted !== 'object') { + return null; + } + return String(persisted.id || '') === String(record.id) + && String(persisted.examId || '') === String(record.examId) + && String(persisted.sessionId || '') === String(record.sessionId) + ? persisted + : null; + }; + window.addEventListener('message', (event) => { const data = event.data || {}; const type = data.type; + const payload = data && typeof data.data === 'object' ? data.data : data; + const matched = findFallbackSessionByWindow(event.source); + if (!matched || !matched.rec) return; + const isLocalFile = window.location && window.location.protocol === 'file:'; + if (isLocalFile ? event.origin !== 'null' : event.origin !== window.location.origin) return; + const allowedSources = new Set(['practice_page', 'inline_collector', 'listening_record_bridge', 'suite_placeholder']); + if (!allowedSources.has(data.source || payload.source)) return; + const permitsPreInit = type === 'REQUEST_INIT' + || (type === 'SESSION_READY' && payload.initialized !== true); + if (!permitsPreInit && ( + !matched.rec.windowSessionToken + || payload.windowSessionToken !== matched.rec.windowSessionToken + )) { + return; + } if (type === 'SESSION_READY') { - const payload = data && typeof data.data === 'object' ? data.data : data; - const matched = findFallbackSessionByWindow(event.source); if (payload && payload.initialized === false) { sendFallbackInit(matched); return; @@ -15718,71 +17034,99 @@ function setupMessageListener() { } } catch (_) { } } else if (type === 'REQUEST_INIT') { - sendFallbackInit(findFallbackSessionByWindow(event.source)); + sendFallbackInit(matched); } else if (type === 'VOCAB_HIGHLIGHT_SAVE') { const payload = data.data && typeof data.data === 'object' ? data.data : data; - saveReadingHighlightVocab(payload).catch((error) => { + const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : ''; + if (!requestId) return; + saveReadingHighlightVocab(payload).then((saved) => { + sendFallbackVocabOutcome(matched.rec, payload, Boolean(saved), saved ? '' : 'save_failed'); + }).catch((error) => { console.warn('[VocabStore] 阅读高亮生词保存异常:', error); + sendFallbackVocabOutcome(matched.rec, payload, false, 'save_failed'); }); } else if (type === 'PRACTICE_COMPLETE' || type === 'practice_completed') { const payload = extractCompletionPayload(data) || {}; const sessionId = extractCompletionSessionId(data); - const matchedByWindow = findFallbackSessionByWindow(event.source); + const matchedByWindow = matched; const rec = sessionId ? (fallbackExamSessions.get(sessionId) || (matchedByWindow && matchedByWindow.rec)) : (matchedByWindow && matchedByWindow.rec); const recSessionId = rec && (rec.sessionId || (matchedByWindow && matchedByWindow.sid) || sessionId); if (recSessionId && payload && typeof payload === 'object') { payload.sessionId = recSessionId; } + if (!payload.submissionId || !recSessionId) return; + const receiptKey = payload.submissionId && recSessionId + ? `${recSessionId}:${String(payload.submissionId)}` + : ''; + if (rec && receiptKey && rec.practiceSubmitReceipt === receiptKey) { + sendFallbackSubmitOutcome(rec, payload, true); + return; + } const shouldNotify = shouldAnnounceCompletion(recSessionId || sessionId); - if (rec) { - console.log('[System] 收到练习完成,保存 canonical 记录'); - const cleanupAfterCompletion = () => { - try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { } - try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { } - }; - savePracticeCompletionRecord(rec.examId, payload).then( - () => { - // 保存成功:提示完成、展示摘要、同步记录。 - cleanupAfterCompletion(); - if (shouldNotify) { - showMessage('练习已完成,正在更新记录...', 'success'); - showCompletionSummary(payload); - } - setTimeout(syncPracticeRecords, 300); - }, - (saveError) => { - // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要, - // 避免在记录未落库时误导用户;同步一次以反映真实状态。 - console.error('[System] 练习完成记录保存失败:', saveError); - cleanupAfterCompletion(); - if (shouldNotify) { - showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error'); - } - setTimeout(syncPracticeRecords, 300); + const cleanupAfterCompletion = () => { + try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { } + if (rec && receiptKey) { + try { if (rec.submitCleanupTimer) clearTimeout(rec.submitCleanupTimer); } catch (_) { } + rec.submitCleanupTimer = setTimeout(() => { + try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { } + }, 120000); + if (rec.submitCleanupTimer && typeof rec.submitCleanupTimer.unref === 'function') { + rec.submitCleanupTimer.unref(); } - ); - } else { - console.log('[System] 收到练习完成消息,正在同步记录...'); + return; + } + try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { } + }; + const onCompletionSaved = async (savedRecord) => { + const persistedRecord = await verifyFallbackPracticeCompletionRecord(savedRecord); + if (!persistedRecord) { + throw new Error('canonical_completion_readback_failed'); + } + if (rec && receiptKey) rec.practiceSubmitReceipt = receiptKey; + sendFallbackSubmitOutcome(rec, payload, true); + // 保存成功:提示完成、展示摘要、同步记录。 + cleanupAfterCompletion(); if (shouldNotify) { showMessage('练习已完成,正在更新记录...', 'success'); showCompletionSummary(payload); } - setTimeout(syncPracticeRecords, 300); + setTimeout(() => ensurePracticeRecordsSync('completion-saved'), 300); + }; + const onCompletionSaveFailed = (saveError) => { + sendFallbackSubmitOutcome(rec, payload, false, 'save_failed'); + // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要, + // 避免在记录未落库时误导用户;同步一次以反映真实状态。 + console.error('[System] 练习完成记录保存失败:', saveError); + cleanupAfterCompletion(); + if (shouldNotify) { + showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error'); + } + setTimeout(() => ensurePracticeRecordsSync('completion-save-failed'), 300); + }; + if (rec) { + console.log('[System] 收到练习完成,保存 canonical 记录'); + savePracticeCompletionRecord(rec.examId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed); + } else { + // 会话映射缺失(例如主页刷新后 fallbackExamSessions 已被清空)。此前这里只做只读同步, + // 记录一个字都不写却提示“练习已完成”。改为用消息自带的 examId 走同一条持久化路径。 + const payloadExamId = resolveCompletionExamId(data, payload); + if (payloadExamId) { + console.log('[System] 会话映射缺失,改用消息自带 examId 保存 canonical 记录:', payloadExamId); + savePracticeCompletionRecord(payloadExamId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed); + } else { + // 连 examId 都没有就无法归属到任何题目,必须明确报错,绝不能报成功。 + console.error('[System] 练习完成消息缺少 examId,无法保存记录'); + sendFallbackSubmitOutcome(rec, payload, false, 'missing_exam_id'); + if (shouldNotify) { + showMessage('练习已完成,但记录保存失败:缺少题目标识,无法归档本次练习。', 'error'); + } + setTimeout(() => ensurePracticeRecordsSync('completion-missing-exam-id'), 300); + } } } }); } -function setupStorageSyncListener() { - window.addEventListener('storage-sync', (event) => { - console.log('[System] 收到存储同步事件,正在更新练习记录...', event.detail); - //可以选择性地只更新受影响的key,但为了简单起见,我们直接同步所有记录 - // if (event.detail && event.detail.key === 'practice_records') { - syncPracticeRecords(); - // } - }); -} - function normalizeFallbackAnswerValue(value) { if (value === null || value === undefined) { return ''; @@ -15962,9 +17306,9 @@ async function saveFallbackSpellingErrors(examId, realData, exam = {}) { } } -function findExamForCompletion(examId, realData = {}) { - const list = typeof getExamIndexState === 'function' ? getExamIndexState() : []; - let exam = Array.isArray(list) ? (list.find(e => e.id === examId) || {}) : {}; +function findExamForCompletion(examId, realData = {}, examIndex = []) { + const list = Array.isArray(examIndex) ? examIndex : []; + let exam = list.find(e => e.id === examId) || {}; if (exam.id || !realData) { return exam; @@ -16052,31 +17396,45 @@ async function savePracticeCompletionRecord(examId, realData) { return null; } - const api = window.PracticeRecordAPI; - if (!api || typeof api.saveCompletion !== 'function') { - throw new Error('统一练习记录 API 未就绪'); - } - const exam = findExamForCompletion(examId, realData); + const examIndex = await resolveActiveExamIndex(); + const exam = findExamForCompletion(examId, realData, examIndex); const category = resolveCompletionCategory(exam, realData); - const record = await api.saveCompletion(realData, { - examId, - examEntry: exam, - metadata: { + // 启动时捕获的题库配置 ID:优先取 PRACTICE_COMPLETE 消息或 realData 已显式透传的值, + // 否则显式写入 null(保留 key),让 AppData provenance 不再回退到当前激活题库, + // 避免用户在考试过程中切换题库导致记录来源不一致。 + const launchLibraryConfigurationId = (realData && realData.libraryConfigurationId != null + && realData.libraryConfigurationId !== '') + ? realData.libraryConfigurationId + : (realData && realData.metadata && realData.metadata.libraryConfigurationId != null + && realData.metadata.libraryConfigurationId !== '') + ? realData.metadata.libraryConfigurationId + : null; + const receipt = await window.AppData.practice.completeAttempt({ + record: Object.assign({}, realData, { + examId, + title: realData.title || exam.title || '', + category, + frequency: exam.frequency || realData.frequency || 'unknown', + type: exam.type || realData.type || null, + metadata: Object.assign({}, realData.metadata || {}, { examId, examTitle: exam.title || realData.title || '', category, frequency: exam.frequency || realData.frequency || 'unknown', - type: exam.type || realData.type || null - } - }, exam, { - currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '0.6.2-fix', - maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000, - updateStats: true + type: exam.type || realData.type || null, + libraryConfigurationId: launchLibraryConfigurationId + }) + }), + operationId: realData.operationId + || realData.messageId + || (realData.submissionId + ? `practice-complete:${examId}:${realData.sessionId || 'session'}:${realData.submissionId}` + : undefined) }); await saveFallbackSpellingErrors(examId, realData, exam); console.log('[PracticeRecord] 练习完成数据已保存到 canonical store'); - return record; + return receipt.record; } catch (e) { console.error('[PracticeRecord] 保存练习记录失败:', e); throw e; @@ -16145,14 +17503,14 @@ function getOverviewView() { return overviewViewInstance; } -function updateOverview() { +function updateOverview(examIndex = []) { const categoryContainer = document.getElementById('category-overview'); if (!categoryContainer) { console.warn('[Overview] 找不到 category-overview 容器'); return; } - const currentExamIndex = getExamIndexState(); + const currentExamIndex = Array.isArray(examIndex) ? examIndex : []; const statsService = window.AppServices && window.AppServices.overviewStats; const stats = statsService ? statsService.calculate(currentExamIndex) : @@ -16578,10 +17936,35 @@ function recordMatchesExamType(record, targetType, examIndex) { return true; } +// 练习记录渲染前的来源过滤。判定本身不在这里实现,而是复用 +// js/data/practiceRecordSource.js(与 practice.stats / achievements.progress 投影器同源), +// 因为“列表看不见但计入统计”的 bug 正是由两处各写一套判定造成的。 +// +// 用 filterRecordsForHistoryView 而不是 filterRealPracticeRecords:两者对"真实记录"的 +// 判定完全相同,前者额外放行新手引导显式登记的演示记录 id(引导需要用户看见那一行)。 +// 该例外只存在于视图层,投影器读不到,因此统计与成就仍严格排除演示数据。 +function filterRealPracticeRecordsForView(records) { + const list = Array.isArray(records) ? records : []; + const classifier = window.PracticeRecordSource; + if (!classifier || typeof classifier.filterRecordsForHistoryView !== 'function') { + // core-foundation 里的 appData.js 缺少该模块会直接抛错、应用根本起不来, + // 所以走到这里只能是加载顺序被破坏。此时绝不本地复刻判定:显式报错并保留全部记录, + // 宁可多显示演示记录,也不能重演"真实记录被吃掉、练习记录页整页空白"。 + console.error('[PracticeHistory] PracticeRecordSource 未加载,已跳过演示记录过滤(判定必须与统计/成就同源)'); + return list; + } + return classifier.filterRecordsForHistoryView(list); +} + // Phase 3: 练习记录视图更新 - 保留在 main.js(依赖多个组件,暂不迁移) -function updatePracticeView() { - const rawRecords = getPracticeRecordsState(); - const records = rawRecords.filter((record) => record && (record.dataSource === 'real' || record.dataSource === undefined)); +function updatePracticeView(recordsSnapshot = [], examIndexSnapshot = []) { + const rawRecords = Array.isArray(recordsSnapshot) ? recordsSnapshot : []; + const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : []; + // 排除演示/种子记录。判定必须与 practice.stats / achievements.progress 两个投影器 + // 完全一致,否则会重演“演示记录在列表里看不见,却计入成绩统计和成就解锁”。 + // 唯一权威定义在 js/data/practiceRecordSource.js(含“dataSource 缺失即真实记录”, + // 该语义曾因被收窄导致练习记录页整页空白,不得回退)。 + const records = filterRealPracticeRecordsForView(rawRecords); const stats = window.PracticeStats; const summary = stats && typeof stats.calculateSummary === 'function' @@ -16610,10 +17993,9 @@ function updatePracticeView() { const examType = getCurrentExamType(); if (examType !== 'all') { if (stats && typeof stats.filterByExamType === 'function') { - recordsToShow = stats.filterByExamType(recordsToShow, getExamIndexState(), examType); + recordsToShow = stats.filterByExamType(recordsToShow, examIndex, examType); } else { - const examIndexSnapshot = getExamIndexState(); - recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndexSnapshot)); + recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndex)); } } @@ -16645,7 +18027,7 @@ function updatePracticeView() { const priorityRenderer = ensurePracticePriorityRenderer(); if (priorityRenderer && typeof priorityRenderer.update === 'function') { - priorityRenderer.update(recordsForInsights, getExamIndexState(), { examType }); + priorityRenderer.update(recordsForInsights, examIndex, { examType }); } // --- 4. Render history list --- @@ -16677,7 +18059,7 @@ function searchPracticeHistory(query) { if (clearButton) { clearButton.hidden = window.__practiceHistoryQuery.length === 0; } - updatePracticeView(); + startPracticeRecordsSyncInBackground('history-search', { forceRender: true }); } function clearPracticeHistorySearch() { @@ -16691,20 +18073,20 @@ function clearPracticeHistorySearch() { searchPracticeHistory(''); } -function refreshBrowseProgressFromRecords(recordsOverride = null) { +function refreshBrowseProgressFromRecords(records, examIndex) { try { - const records = Array.isArray(recordsOverride) - ? recordsOverride - : (typeof getPracticeRecordsState === 'function' - ? getPracticeRecordsState() - : (Array.isArray(window.practiceRecords) ? window.practiceRecords : [])); + const recordSnapshot = Array.isArray(records) ? records : []; + const indexSnapshot = Array.isArray(examIndex) ? examIndex : []; if (typeof updateBrowseAnchorsFromRecords === 'function') { - updateBrowseAnchorsFromRecords(records); + updateBrowseAnchorsFromRecords(recordSnapshot, indexSnapshot); + } + if (typeof rebuildBrowseCompletionIndex === 'function') { + rebuildBrowseCompletionIndex(recordSnapshot); } const browseView = document.getElementById('browse-view'); const isBrowseActive = browseView && browseView.classList.contains('active'); if (isBrowseActive && typeof loadExamList === 'function') { - loadExamList(); + loadExamList(indexSnapshot); } } catch (error) { console.warn('[Browse] 刷新浏览进度失败:', error); @@ -16717,28 +18099,11 @@ function ensurePracticeSessionSyncListener() { return; } practiceSessionEventBound = true; - document.addEventListener('practiceSessionCompleted', (event) => { - try { - const detail = event && event.detail ? event.detail : {}; - let record = detail.practiceRecord; - if (record && typeof record === 'object') { - record = enrichPracticeRecordForUI(record); - const current = getPracticeRecordsState(); - const filtered = Array.isArray(current) - ? current.filter((item) => item && item.id !== record.id) - : []; - setPracticeRecordsState([record, ...filtered]); - updatePracticeView(); - refreshBrowseProgressFromRecords([record, ...filtered]); - } - } catch (syncError) { - console.warn('[PracticeView] practiceSessionCompleted 事件处理失败:', syncError); - } finally { - // 仍然执行一次全面同步,确保 ScoreStorage/StorageRepo 状态一致 - setTimeout(() => { - try { syncPracticeRecords(); } catch (_) { } - }, 200); - } + document.addEventListener('practiceSessionCompleted', () => { + startPracticeRecordsSyncInBackground('session-completed', { + mode: 'summary', + forceRender: true + }); }); } @@ -16855,10 +18220,6 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu try { window.app.browseCategory(category, type, filterMode, path); console.log('[browseCategory] Called app.browseCategory with filterMode:', filterMode); - // 常规模式仍需刷新题库;频率模式由 browseController 接管 - if (!filterMode) { - setTimeout(() => loadExamList(), 100); - } return; } catch (error) { console.warn('[browseCategory] window.app.browseCategory 调用失败,使用降级路径:', error); @@ -16896,12 +18257,16 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu } } -function filterByType(type) { +async function filterByType(type, examIndexOverride = null) { const requestedType = type; + let examIndex = Array.isArray(examIndexOverride) ? examIndexOverride : []; try { + if (!Array.isArray(examIndexOverride)) { + examIndex = await resolveActiveExamIndex(); + } const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function' - ? window.hasActiveListeningLibrary() - : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening')); + ? window.hasActiveListeningLibrary(examIndex) + : examIndex.some((exam) => exam && exam.type === 'listening'); if (requestedType === 'listening' && !listeningAvailable) { type = 'all'; if (typeof window.showMessage === 'function') { @@ -16928,7 +18293,7 @@ function filterByType(type) { if (window.browseController && window.browseController.currentMode !== 'default' && typeof window.browseController.resetToDefault === 'function') { - window.browseController.resetToDefault(); + window.browseController.resetToDefault(examIndex); } // 更新题库浏览筛选按钮的 active 状态 @@ -16953,12 +18318,13 @@ function filterByType(type) { } // 刷新题库列表 - loadExamList(); + await loadExamList(examIndex); } // 应用分类筛选(供 App/总览调用) -function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) { +async function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) { try { + const indexSnapshot = await resolveActiveExamIndex(); const memorizeSelectionActive = isReadingMemorizeBrowseMode(); if (memorizeSelectionActive) { category = 'all'; @@ -16980,7 +18346,6 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat // 若未显式给出类型,则根据当前题库推断(同时存在时不限定类型) if (!type || type === 'all') { try { - const indexSnapshot = getExamIndexState(); const hasReading = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'reading'); const hasListening = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'listening'); if (hasReading && !hasListening) type = 'reading'; @@ -16992,8 +18357,8 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat const normalizedType = normalizeExamType(type); const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null; const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function' - ? window.hasActiveListeningLibrary() - : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening')); + ? window.hasActiveListeningLibrary(indexSnapshot) + : indexSnapshot.some((exam) => exam && exam.type === 'listening'); const effectiveFilterMode = listeningAvailable ? filterMode : null; const effectiveType = (!listeningAvailable && normalizedType === 'listening') ? 'all' : normalizedType; @@ -17006,9 +18371,9 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat if (window.browseController) { try { if (!window.browseController.buttonContainer) { - window.browseController.initialize('type-filter-buttons'); + window.browseController.initialize('type-filter-buttons', indexSnapshot); } - window.browseController.setMode(effectiveFilterMode); + window.browseController.setMode(effectiveFilterMode, indexSnapshot); } catch (error) { console.warn('[Browse] 切换浏览模式失败:', error); } @@ -17020,7 +18385,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat if (window.browseController && window.browseController.currentMode !== 'default' && typeof window.browseController.resetToDefault === 'function') { - window.browseController.resetToDefault(); + window.browseController.resetToDefault(indexSnapshot); } } @@ -17034,7 +18399,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat // 如果是频率模式,setMode 已经处理了刷新,不需要再次调用 loadExamList // 只有在默认模式下才显式调用 if (!effectiveFilterMode) { - loadExamList(); + await loadExamList(indexSnapshot); } // 若未在浏览视图,则尽力切换 @@ -17055,16 +18420,21 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat } // Initialize browse view when it's activated -function initializeBrowseView() { +async function initializeBrowseView(options = {}) { console.log('[System] Initializing browse view...'); - startPracticeRecordsSyncInBackground('browse-view'); + const [examIndex] = await Promise.all([ + resolveActiveExamIndex(), + typeof window.whenBrowseViewPreferencesReady === 'function' + ? window.whenBrowseViewPreferencesReady() + : Promise.resolve() + ]); // 初始化 browseController if (window.browseController && !window.browseController.buttonContainer) { - window.browseController.initialize('type-filter-buttons'); + window.browseController.initialize('type-filter-buttons', examIndex); } if (typeof window.refreshListeningAvailabilityUI === 'function') { - window.refreshListeningAvailabilityUI(); + window.refreshListeningAvailabilityUI(examIndex); } const persisted = getPersistedBrowseFilter(); @@ -17076,12 +18446,11 @@ function initializeBrowseView() { setBrowseTitle(formatBrowseTitle('all', 'all')); } - ensurePracticeRecordsSync('browse-view').then(() => { - refreshBrowseProgressFromRecords(); - }); setupBrowseSortControl(); setupBrowseFrequencyFilterControl(); - loadExamList(); + if (!options.skipLoad) { + await loadExamList(examIndex); + } } function normalizeBrowseFrequencyFilter(value) { @@ -17103,11 +18472,27 @@ function refreshBrowseResults() { loadExamList(); } -function setupBrowseControls() { +let browseControlsSeeded = false; +async function setupBrowseControls() { + if (!browseControlsSeeded) { + try { + const browse = await window.AppData.preferences.getBrowse(); + if (browse) { + window.__browseSortMode = browse.sortMode || window.__browseSortMode; + window.__browseFrequencyFilter = browse.frequencyFilter || window.__browseFrequencyFilter; + } + } catch (_) { /* defaults remain active */ } + browseControlsSeeded = true; + } setupBrowseSortControl(); setupBrowseFrequencyFilterControl(); } +async function persistBrowsePreference(patch) { + const current = await window.AppData.preferences.getBrowse() || {}; + await window.AppData.preferences.setBrowse(Object.assign({}, current, patch)); +} + function setupBrowseSortControl() { const sortSelect = document.getElementById('browse-sort-select'); if (!sortSelect || sortSelect.dataset.bound === 'true') { @@ -17118,22 +18503,12 @@ function setupBrowseSortControl() { return mode === 'frequency-desc' || mode === 'difficulty-desc' ? mode : 'default'; }; let savedMode = String(window.__browseSortMode || '').trim().toLowerCase(); - if (!savedMode) { - try { - savedMode = String(window.localStorage.getItem('browse_sort_mode') || 'default').trim().toLowerCase(); - } catch (_) { - savedMode = 'default'; - } - } + if (!savedMode) savedMode = 'default'; sortSelect.value = normalizeSortMode(savedMode); window.__browseSortMode = sortSelect.value; sortSelect.addEventListener('change', () => { window.__browseSortMode = normalizeSortMode(sortSelect.value); - try { - window.localStorage.setItem('browse_sort_mode', window.__browseSortMode); - } catch (_) { - // ignore storage failures - } + persistBrowsePreference({ sortMode: window.__browseSortMode }).catch(console.warn); refreshBrowseResults(); }); sortSelect.dataset.bound = 'true'; @@ -17158,13 +18533,6 @@ function setupBrowseFrequencyFilterControl() { return; } let savedFilter = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all'); - if (savedFilter === 'all') { - try { - savedFilter = normalizeBrowseFrequencyFilter(window.localStorage.getItem('browse_frequency_filter') || 'all'); - } catch (_) { - savedFilter = 'all'; - } - } window.__browseFrequencyFilter = savedFilter; updateBrowseFrequencyButtons(savedFilter); } @@ -17174,11 +18542,7 @@ function filterByFrequency(filter) { const current = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all'); const next = requested !== 'all' && requested === current ? 'all' : requested; window.__browseFrequencyFilter = next; - try { - window.localStorage.setItem('browse_frequency_filter', next); - } catch (_) { - // ignore storage failures - } + persistBrowsePreference({ frequencyFilter: next }).catch(console.warn); updateBrowseFrequencyButtons(next); refreshBrowseResults(); } @@ -17223,33 +18587,36 @@ function filterRecordsByType(type) { setTimeout(window.updateSegmentedIndicators, 10); } - updatePracticeView(); + startPracticeRecordsSyncInBackground('record-type-filter', { forceRender: true }); } -function loadExamList() { - setupBrowseControls(); +async function loadExamList(examIndexOverride = null) { + await setupBrowseControls(); + const examIndex = Array.isArray(examIndexOverride) + ? examIndexOverride + : await resolveActiveExamIndex(); if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') { - return window.ExamActions.loadExamList(); + return window.ExamActions.loadExamList(examIndex); } console.warn('[main.js] ExamActions.loadExamList 未就绪,尝试加载 browse-view 组'); if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { window.AppLazyLoader.ensureGroup('browse-view').then(function () { setupBrowseControls(); if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') { - window.ExamActions.loadExamList(); + window.ExamActions.loadExamList(examIndex); } else { // 最终降级:直接 DOM 渲染 - loadExamListFallback(); + loadExamListFallback(examIndex); } }).catch(function (err) { console.error('[main.js] browse-view 组加载失败:', err); - loadExamListFallback(); + loadExamListFallback(examIndex); }); } else { // 无懒加载器,直接降级 - loadExamListFallback(); + loadExamListFallback(examIndex); } } @@ -17298,13 +18665,11 @@ function clearReadingMemorizeBrowseMode() { } } -function selectReadingMemorizeExam(examId) { +async function selectReadingMemorizeExam(examId) { if (window.ExamActions && typeof window.ExamActions.launchReadingMemorizeExam === 'function') { return window.ExamActions.launchReadingMemorizeExam(examId); } - const list = typeof getExamIndexState === 'function' - ? getExamIndexState() - : (Array.isArray(window.examIndex) ? window.examIndex : []); + const list = await resolveActiveExamIndex(); const exam = Array.isArray(list) ? list.find(function (item) { return item && String(item.id) === String(examId); }) : null; @@ -17401,10 +18766,10 @@ function createFallbackExamCard(exam, options = {}) { return item; } -function loadExamListFallback() { +function loadExamListFallback(examIndexSnapshot = []) { console.warn('[main.js] 使用降级渲染逻辑'); try { - let examIndex = typeof getExamIndexState === 'function' ? getExamIndexState() : (Array.isArray(window.examIndex) ? window.examIndex : []); + let examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : []; const container = document.getElementById('exam-list-container'); if (!container) return; @@ -17505,83 +18870,11 @@ function loadExamListFallback() { } } -function resetBrowseViewToAll() { - if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') { - return window.ExamActions.resetBrowseViewToAll(); - } - console.warn('[main.js] ExamActions.resetBrowseViewToAll 未就绪'); - - // 清除频率模式状态,确保回到默认列表 - clearReadingMemorizeBrowseMode(); - window.__browseFilterMode = 'default'; - window.__browsePath = null; - - if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { - window.AppLazyLoader.ensureGroup('browse-view').then(function () { - if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') { - window.ExamActions.resetBrowseViewToAll(); - } else { - // 降级:重置状态并重新加载 - if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all'); - loadExamList(); - } - }).catch(function () { - if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all'); - loadExamList(); - }); - } else { - if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all'); - loadExamList(); - } -} - -function displayExams(exams) { - if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') { - return window.ExamActions.displayExams(exams); - } - console.warn('[main.js] ExamActions.displayExams 未就绪,使用降级渲染'); - - // 立即降级渲染(displayExams 需要同步执行) - try { - const container = document.getElementById('exam-list-container'); - if (!container) return; - - // 清除 loading 指示器(修复 P2 bug) - const loadingEl = document.querySelector('#browse-view .loading'); - if (loadingEl) { - loadingEl.style.display = 'none'; - } - - const memorizeSelectionActive = isReadingMemorizeBrowseMode(); - if (typeof window.syncReadingMemorizeBrowseModeUI === 'function') { - window.syncReadingMemorizeBrowseModeUI(); - } - const normalizedExams = memorizeSelectionActive - ? filterReadingMemorizeExamsFallback(exams) - : (Array.isArray(exams) ? exams : []); - if (memorizeSelectionActive && typeof setBrowseTitle === 'function') { - setBrowseTitle('阅读背题选题'); - } - if (normalizedExams.length === 0) { - container.innerHTML = '

未找到匹配的题目

'; - return; - } - - const list = document.createElement('div'); - list.className = 'exam-list'; - normalizedExams.forEach(function (exam) { - if (!exam) return; - list.appendChild(createFallbackExamCard(exam, { - selectionMode: memorizeSelectionActive ? 'reading-memorize' : '', - showMeta: true - })); - }); - container.innerHTML = ''; - container.appendChild(list); - } catch (err) { - console.error('[main.js] displayExams 降级渲染失败:', err); - } -} +// resetBrowseViewToAll / displayExams 的唯一实现在 js/app/examActions.js, +// 由其 IIFE 导出到 window.ExamActions 与 window 上。此处不再重复定义: +// 两个文件同处 browse.bundle.js,重名的顶层声明会与 examActions 的全局写入 +// 静默互相覆盖(历史上 loadExamList 就因此渲染空白)。 +// 调用方请走 window.ExamActions.*(未加载时有 main-entry.js 的懒加载代理兜底)。 function getResourceCore() { return window.ResourceCore || null; @@ -17657,9 +18950,8 @@ function openExam(examId, options = {}) { return showMessage('统一练习入口未就绪:app.openExam 不可用,已阻止打开原始题源 HTML。', 'error'); } -function viewPDF(examId) { - // 增加数组化防御 - const list = getExamIndexState(); +async function viewPDF(examId) { + const list = await resolveActiveExamIndex(); const exam = list.find(e => e.id === examId); if (!exam || !exam.pdfFilename) return showMessage('未找到PDF文件', 'error'); @@ -17729,8 +19021,8 @@ function getViewName(viewName) { } } -function updateSystemInfo() { - const examIndexSnapshot = getExamIndexState(); +function updateSystemInfo(examIndex = []) { + const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : []; if (!examIndexSnapshot || examIndexSnapshot.length === 0) return; const readingExams = examIndexSnapshot.filter(e => e.type === 'reading'); const listeningExams = examIndexSnapshot.filter(e => e.type === 'listening'); @@ -17859,14 +19151,14 @@ async function getActiveLibraryConfigurationKey() { if (manager && typeof manager.getActiveLibraryConfigurationKey === 'function') { return await manager.getActiveLibraryConfigurationKey(); } - return await storage.get('active_exam_index_key', 'exam_index'); + return window.AppData.library.getActive(); } async function getLibraryConfigurations() { const manager = await ensureLibraryManagerReady(); if (manager && typeof manager.getLibraryConfigurations === 'function') { return await manager.getLibraryConfigurations(); } - return await storage.get('exam_index_configurations', []); + return await window.AppData.library.listConfigurations(); } async function saveLibraryConfiguration(name, key, examCount) { const manager = await ensureLibraryManagerReady(); @@ -17888,11 +19180,16 @@ function handleFolderSelection(event) { /* legacy stub - replaced by modal-speci // --- Functions Restored from Backup --- +let debouncedExamSearch = null; + function searchExams(query) { toggleSearchClearButton(query); if (window.performanceOptimizer && typeof window.performanceOptimizer.debounce === 'function') { - const debouncedSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search'); - debouncedSearch(query); + // 跨 input 事件复用同一个 debounce 闭包,避免每个字符都排队一次搜索。 + if (!debouncedExamSearch) { + debouncedExamSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search'); + } + debouncedExamSearch(query); } else { // Fallback: direct call if optimizer not available performSearch(query); @@ -17923,8 +19220,8 @@ function clearSearch() { searchExams(''); } -function getBrowseFilteredExamBase() { - const examIndex = getExamIndexState(); +function getBrowseFilteredExamBase(examIndexSnapshot = []) { + const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : []; const activeCategory = typeof getCurrentCategory === 'function' ? getCurrentCategory() : 'all'; const activeExamType = typeof getCurrentExamType === 'function' ? getCurrentExamType() : 'all'; const isFrequencyMode = window.__browseFilterMode && window.__browseFilterMode !== 'default'; @@ -17960,7 +19257,7 @@ function getBrowseFilteredExamBase() { return list; } -function performSearch(query) { +async function performSearch(query) { const normalizedQuery = query.toLowerCase().trim(); if (!normalizedQuery) { loadExamList(); @@ -17969,7 +19266,7 @@ function performSearch(query) { // 调试日志 console.log('[Search] 执行搜索,查询词:', normalizedQuery); - const searchBase = getBrowseFilteredExamBase(); + const searchBase = getBrowseFilteredExamBase(await resolveActiveExamIndex()); console.log('[Search] 当前筛选后索引数量:', searchBase.length); const searchResults = searchBase.filter(exam => { if (exam.searchText) { @@ -17981,7 +19278,11 @@ function performSearch(query) { }); console.log('[Search] 搜索结果数量:', searchResults.length); - displayExams(searchResults); + if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') { + window.ExamActions.displayExams(searchResults); + } else if (typeof window.displayExams === 'function') { + window.displayExams(searchResults); + } } async function toggleBulkDelete() { @@ -17993,7 +19294,7 @@ async function toggleBulkDelete() { if (typeof showMessage === 'function') { showMessage('批量管理模式已开启,点击记录进行选择', 'info'); } - updatePracticeView(); + await syncPracticeRecords({ forceRender: true }); return; } @@ -18013,7 +19314,7 @@ async function toggleBulkDelete() { clearSelectedRecordsState(); refreshBulkDeleteButton(); - updatePracticeView(); + await syncPracticeRecords({ forceRender: true }); } async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) { @@ -18025,21 +19326,21 @@ async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) { const records = await listCanonicalPracticeRecords(); const baseList = Array.isArray(records) ? records : []; - const recordsToKeep = baseList.filter(record => !normalizedIds.includes(normalizeRecordId(record && record.id))); - - const deletedCount = baseList.length - recordsToKeep.length; + const recordIds = new Set(baseList.map((record) => normalizeRecordId(record && record.id)).filter(Boolean)); + const deletedCount = normalizedIds.filter((id) => recordIds.has(id)).length; if (deletedCount === 0) { showMessage('未找到可删除的记录', 'warning'); return; } - await persistPracticeRecordsAndRefresh(recordsToKeep, 'bulk-delete'); + await window.AppData.practice.deleteMany({ recordIds: normalizedIds }); + await syncPracticeRecords({ forceRender: true, trigger: 'bulk-delete' }); showMessage(`已删除 ${deletedCount} 条记录`, 'success'); console.log(`[System] 批量删除了 ${deletedCount} 条练习记录`); } -function toggleRecordSelection(recordId) { +async function toggleRecordSelection(recordId) { if (!getBulkDeleteModeState()) return; const normalizedId = normalizeRecordId(recordId); @@ -18053,7 +19354,7 @@ function toggleRecordSelection(recordId) { } else { addSelectedRecordState(normalizedId); } - updatePracticeView(); // Re-render to show selection state + await syncPracticeRecords({ forceRender: true }); } @@ -18075,15 +19376,19 @@ async function deleteRecord(recordId) { const confirmMessage = `确定要删除这条练习记录吗?\n\n题目: ${record.title}\n时间: ${new Date(record.date).toLocaleString()}\n\n此操作不可恢复。`; if (confirm(confirmMessage)) { - const nextRecords = records.filter((record) => String(record.id) !== String(recordId)); - await persistPracticeRecordsAndRefresh(nextRecords, 'single-delete'); + await window.AppData.practice.delete({ recordId }); + await syncPracticeRecords({ forceRender: true, trigger: 'single-delete' }); showMessage('记录已删除', 'success'); } } async function clearPracticeData() { if (confirm('确定要清除所有练习记录吗?此操作不可恢复。')) { - await persistPracticeRecordsAndRefresh([], 'clear-all'); + await window.AppData.practice.clear(); + await syncPracticeRecords({ forceRender: true, trigger: 'clear-all' }); + if (window.AppData && window.AppData.recovery && typeof window.AppData.recovery.clear === 'function') { + await window.AppData.recovery.clear(); + } processedSessions.clear(); clearSelectedRecordsState(); setBulkDeleteModeState(false); @@ -18093,44 +19398,11 @@ async function clearPracticeData() { } async function clearCache() { - const confirmMessage = '确定要清除所有缓存数据并清空练习记录吗?'; - if (!confirm(confirmMessage)) { - return; - } - - const localLegacyKeys = [ - 'exam_system_practice_records', - 'upgrade_v1_1_0_cleanup_done', - 'browse_state', - 'hasSeenGplLicense', - 'theme', - 'bloom-theme-mode', - 'blue-theme-mode' - ]; - - try { - if (window.storage && typeof storage.clear === 'function') { - await storage.clear(); - } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.clear === 'function') { - await window.PracticeRecordAPI.clear({ updateStats: true }); - } else { - throw new Error('统一练习记录 API 未就绪'); - } - } catch (error) { - console.warn('[clearCache] failed to clear managed storage:', error); - } - - localLegacyKeys.forEach((key) => { - try { localStorage.removeItem(key); } catch (_) { } - }); - setPracticeRecordsState([]); - processedSessions.clear(); - if (window.performanceOptimizer && typeof window.performanceOptimizer.cleanup === 'function') { - window.performanceOptimizer.cleanup(); + if (!window.SiteDataReset || typeof window.SiteDataReset.request !== 'function') { + showMessage('清除失败:全量重置服务未就绪', 'error'); + return false; } - - showMessage('缓存与练习记录已清除', 'success'); - setTimeout(() => { location.reload(); }, 1000); + return window.SiteDataReset.request(); } let libraryConfigViewInstance = null; @@ -18177,7 +19449,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { } seenKeys.add(key); normalized.push({ - name: key === 'exam_index' ? '默认题库' : key, + name: key, key, examCount: 0, timestamp: now @@ -18206,15 +19478,6 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { } } - if (!key && typeof record.name === 'string') { - const nameKey = normalizeKey(record.name); - if (/^exam_index(_\d+)?$/.test(nameKey)) { - key = nameKey; - record.key = key; - mutated = true; - } - } - if (!key) { mutated = true; continue; @@ -18249,7 +19512,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { seenKeys.add(key); if (typeof record.name !== 'string' || !record.name.trim()) { - record.name = key === 'exam_index' ? '默认题库' : key; + record.name = key; mutated = true; } else { record.name = record.name.trim(); @@ -18285,6 +19548,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) { async function resolveLibraryConfigurations() { const rawConfigs = await getLibraryConfigurations(); + const activeIndex = await resolveActiveExamIndex(); let configs = Array.isArray(rawConfigs) ? rawConfigs : []; let mutated = false; @@ -18292,28 +19556,22 @@ async function resolveLibraryConfigurations() { configs = normalizedResult.normalized; mutated = normalizedResult.mutated; - if (configs.length === 0) { - try { - const count = getExamIndexState().length; - configs = [{ - name: '默认题库', - key: 'exam_index', - examCount: count, - timestamp: Date.now() - }]; - mutated = true; - const activeKey = await storage.get('active_exam_index_key'); - if (!activeKey) { - await storage.set('active_exam_index_key', 'exam_index'); - } - } catch (error) { - console.warn('[LibraryConfig] 无法初始化默认题库配置', error); - } + if (!configs.some(config => config && config.builtIn === true)) { + configs.unshift({ + name: '默认题库', + key: '', + id: null, + builtIn: true, + sourceType: 'built-in-manifest', + examCount: activeIndex.length + }); } if (mutated) { try { - await storage.set('exam_index_configurations', configs); + for (const config of configs) { + if (config && config.key && config.builtIn !== true) await window.AppData.library.updateConfiguration(config); + } } catch (error) { console.warn('[LibraryConfig] 无法同步题库配置记录', error); } @@ -18369,15 +19627,14 @@ async function deleteLibraryConfiguration(key) { async function debugCompareActiveIndexWithDefault() { try { const activeKey = await getActiveLibraryConfigurationKey(); - const activeIndex = Array.isArray(getExamIndexState()) ? getExamIndexState() : []; + const activeIndex = await resolveActiveExamIndex(); const defaultIndex = typeof window.getReadingExamIndex === 'function' ? window.getReadingExamIndex().map((exam) => Object.assign({}, exam, { type: 'reading' })) : (Array.isArray(window.__READING_EXAM_INDEX__) ? window.__READING_EXAM_INDEX__.map((exam) => Object.assign({}, exam, { type: 'reading' })) : []); const defaultListening = Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : []; - const storedDefault = await storage.get('exam_index', []); - const combinedDefault = storedDefault.length ? storedDefault : [...defaultIndex, ...defaultListening]; + const combinedDefault = [...defaultIndex, ...defaultListening]; const normalizeTail = (path) => { const p = String(path || '').replace(/\\/g, '/').split('/').filter(Boolean); @@ -18473,8 +19730,8 @@ function renderLibraryConfigFallback(container, configs, options) { if (!config) { return; } - const isActive = activeKey === config.key; - const isDefault = config.key === 'exam_index'; + const isDefault = config.builtIn === true; + const isActive = isDefault ? activeKey == null : activeKey === config.key; const item = document.createElement('div'); item.className = 'library-config-panel__item' + (activeKey === config.key ? ' library-config-panel__item--active' : ''); @@ -18498,7 +19755,7 @@ function renderLibraryConfigFallback(container, configs, options) { switchBtn.type = 'button'; switchBtn.className = 'btn btn-secondary'; switchBtn.dataset.configAction = 'switch'; - switchBtn.dataset.configKey = config.key; + switchBtn.dataset.configKey = config.key || ''; if (isActive) { switchBtn.dataset.configActive = '1'; } @@ -18670,10 +19927,7 @@ async function showLibraryConfigListV2(options) { // 切换题库配置 async function switchLibraryConfig(configKey) { - const key = typeof configKey === 'string' ? configKey.trim() : ''; - if (!key) { - return; - } + const key = typeof configKey === 'string' && configKey.trim() ? configKey.trim() : null; try { const activeKey = await getActiveLibraryConfigurationKey(); if (activeKey === key) { @@ -18701,10 +19955,6 @@ async function deleteLibraryConfig(configKey) { if (!key) { return; } - if (key === 'exam_index') { - showMessage('默认题库不可删除', 'warning'); - return; - } try { const activeKey = await getActiveLibraryConfigurationKey(); if (activeKey === key) { @@ -18839,12 +20089,12 @@ function openExamWithFallback(exam, delay = 600) { } // Phase 3: 随机练习 - 已迁移到 app-actions.js -function startRandomPractice(category, type = 'reading', filterMode = null, path = null) { +async function startRandomPractice(category, type = 'reading', filterMode = null, path = null) { if (window.AppActions && typeof window.AppActions.startRandomPractice === 'function') { return window.AppActions.startRandomPractice(category, type, filterMode, path); } // 降级:直接执行 - const list = getExamIndexState(); + const list = await resolveActiveExamIndex(); const normalizedType = (!type || type === 'all') ? null : type; const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null; @@ -18890,6 +20140,7 @@ ensurePracticeSessionSyncListener(); if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { global.AppLazyLoader.markProvided([ "js/views/legacyViewBundle.js", + "js/data/practiceRecordSource.js", "js/app/examActions.js", "js/app/spellingErrorCollector.js", "js/app/examSessionMixin.js", diff --git a/js/bundles/core-foundation.bundle.js b/js/bundles/core-foundation.bundle.js index 8504ee72..66ffd500 100644 --- a/js/bundles/core-foundation.bundle.js +++ b/js/bundles/core-foundation.bundle.js @@ -6,34 +6,8 @@ return; } - const FLAG_KEY = '__ielts_test_env__'; const LOCATION_HINTS = ['test_env=1', 'suite_test=1', 'ci=1']; - const readStorageFlag = () => { - try { - if (global.localStorage) { - return global.localStorage.getItem(FLAG_KEY) === 'true'; - } - } catch (error) { - console.warn('[EnvDetector] 无法读取测试标记:', error); - } - return false; - }; - - const persistFlag = (value) => { - try { - if (global.localStorage) { - if (value) { - global.localStorage.setItem(FLAG_KEY, 'true'); - } else { - global.localStorage.removeItem(FLAG_KEY); - } - } - } catch (error) { - console.warn('[EnvDetector] 无法写入测试标记:', error); - } - }; - const shouldActivateFromLocation = () => { if (!global.location) { return false; @@ -50,33 +24,19 @@ } if (shouldActivateFromLocation()) { - this.enableTestEnvironment({ persist: true }); - return true; - } - - if (readStorageFlag()) { - global.__IELTS_FORCE_TEST_ENV__ = true; - return true; - } - - const userAgent = (global.navigator && global.navigator.userAgent) || ''; - if (/\b(playwright|puppeteer|headlesschrome)\b/i.test(userAgent)) { + this.enableTestEnvironment(); return true; } return false; }, - enableTestEnvironment(options = {}) { + enableTestEnvironment() { global.__IELTS_FORCE_TEST_ENV__ = true; - if (options.persist !== false) { - persistFlag(true); - } }, disableTestEnvironment() { global.__IELTS_FORCE_TEST_ENV__ = false; - persistFlag(false); } }; @@ -95,8 +55,6 @@ return; } - const STORAGE_KEY = 'exam_system_log_config_v2'; - // Default configuration const DEFAULT_CONFIG = { level: 'info', @@ -106,7 +64,7 @@ 'PerformanceOptimizer': 'warn', 'System': 'info', 'PracticeRecorder': 'info', - 'ScoreStorage': 'info' + 'DataKernel': 'warn' } }; @@ -132,6 +90,7 @@ this.debug = this.debug.bind(this); this.overrideConsole(); + Promise.resolve().then(() => this.hydrateConfig()); // Output initialization message this.internalLog('info', 'Logger initialized', { @@ -141,41 +100,47 @@ } /** - * Load configuration from localStorage or use defaults + * Build configuration from defaults and explicit bootstrap overrides. */ loadConfig(externalConfig) { - let storedConfig = {}; - try { - const stored = global.localStorage.getItem(STORAGE_KEY); - if (stored) { - storedConfig = JSON.parse(stored); - } - } catch (e) { - // Ignore storage errors - } - return { - level: externalConfig.level || storedConfig.level || DEFAULT_CONFIG.level, + level: externalConfig.level || DEFAULT_CONFIG.level, categories: { ...DEFAULT_CONFIG.categories, - ...(storedConfig.categories || {}), ...(externalConfig.categories || {}) } }; } + async hydrateConfig() { + try { + if (!global.AppData) return; + await global.AppData.ready; + const storedConfig = await global.AppData.preferences.getLogConfig(); + if (!storedConfig || typeof storedConfig !== 'object') return; + this.config = { + level: storedConfig.level || this.config.level, + categories: { ...this.config.categories, ...(storedConfig.categories || {}) } + }; + } catch (error) { + this.nativeConsole.warn('[AppLogger] 无法读取日志配置:', error); + } + } + /** - * Save current configuration to localStorage + * Save current configuration through the preferences domain. */ saveConfig() { - try { - global.localStorage.setItem(STORAGE_KEY, JSON.stringify({ + if (!global.AppData) return Promise.resolve(false); + return global.AppData.ready.then(() => + global.AppData.preferences.setLogConfig({ level: this.config.level, categories: this.config.categories - })); - } catch (e) { - // Ignore storage errors - } + }) + ).then(() => true).catch((error) => { + this.nativeConsole.warn('[AppLogger] 无法保存日志配置:', error); + return false; + }); } /** @@ -366,8876 +331,7249 @@ })(typeof window !== 'undefined' ? window : (typeof global !== 'undefined' ? global : this)); -/* ===== js/utils/storage.js ===== */ -(function initStorage(window) { -'use strict'; - +/* ===== js/data/practiceRecordSource.js ===== */ /** - * 本地存储工具类 - * 提供统一的数据存储和检索接口 + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 */ -const STORAGE_INTERNAL_ACCESS_TOKEN = Symbol('StorageManager.internalAccessToken'); +(function initPracticeRecordSource(global) { + 'use strict'; -const createInternalAccessOptions = (options = {}) => { - return Object.assign({}, options, { - skipPracticeCoreRedirect: true, - internalAccessToken: STORAGE_INTERNAL_ACCESS_TOKEN - }); -}; - -const hasInternalAccessOptions = (options = {}) => { - return Boolean(options && options.internalAccessToken === STORAGE_INTERNAL_ACCESS_TOKEN); -}; - -class StorageManager { - constructor() { - this.prefix = 'exam_system_'; - this.version = '0.6.2-fix'; - this.localStorageAvailable = false; - this.sessionStorageAvailable = false; - this.backendPreferenceKey = this.prefix + 'storage_backend'; - this.indexedDBBlocked = false; - this.volatileMode = false; - this.mode = 'indexeddb'; - this.protectedDataKeys = new Set([ - 'practice_records', - 'user_stats' - ]); - this.persistentKeys = new Set([ - 'practice_records', - 'user_stats', - 'manual_backups', - 'backup_settings', - 'export_history', - 'import_history', - 'exam_index', - 'exam_index_configurations', - 'active_exam_index_key', - 'settings', - 'learning_goals' - ]); - this.ready = this.initializeStorage().catch(error => { - console.error('[Storage] 初始化失败:', error); - throw error; - }); + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; } - async waitForInitialization(skipReady = false) { - if (!skipReady) { - await this.ready; - } - } + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); - isProtectedDataKey(key) { - return this.protectedDataKeys.has(String(key || '')); - } + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); - isProtectedStorageKey(storageKey) { - const key = String(storageKey || ''); - if (!key.startsWith(this.prefix)) { - return false; - } - return this.isProtectedDataKey(key.slice(this.prefix.length)); - } + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); - async getPracticeRecordAPI(options = {}) { - const api = window.PracticeRecordAPI; - if (api) { - return api; - } - if (options.skipReady || !this.ready || this._resolvingPracticeRecordAPI) { - return null; - } - this._resolvingPracticeRecordAPI = true; - try { - await this.ready; - return window.PracticeRecordAPI || null; - } finally { - this._resolvingPracticeRecordAPI = false; - } - } + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); - async readProtectedDataKey(key, defaultValue = null, options = {}) { - const api = await this.getPracticeRecordAPI(options); - if (key === 'practice_records') { - if (api && typeof api.list === 'function') { - return await api.list(); - } - throw new Error('Storage.get(practice_records): PracticeRecordAPI.list not ready'); - } - if (key === 'user_stats') { - if (api && typeof api.readStats === 'function') { - return await api.readStats({ fallback: defaultValue }); - } - throw new Error('Storage.get(user_stats): PracticeRecordAPI.readStats not ready'); - } - return defaultValue; + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); } - /** - * 初始化存储系统 - */ - checkStorageAvailability(getter) { - try { - const store = getter(); - if (!store || typeof store.setItem !== 'function') { - return false; - } - const testKey = this.prefix + 'storage_test_' + Math.random().toString(36).slice(2); - store.setItem(testKey, '1'); - store.removeItem(testKey); - return true; - } catch (_) { - return false; - } + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } - getStoredBackendPreference() { - try { - if (this.sessionStorageAvailable && sessionStorage.getItem(this.backendPreferenceKey)) { - return sessionStorage.getItem(this.backendPreferenceKey); - } - } catch (_) { /* ignore */ } - try { - if (this.localStorageAvailable && localStorage.getItem(this.backendPreferenceKey)) { - return localStorage.getItem(this.backendPreferenceKey); - } - } catch (_) { /* ignore */ } - return null; + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); } - setBackendPreference(mode) { - try { - if (mode === 'session' && this.sessionStorageAvailable) { - sessionStorage.setItem(this.backendPreferenceKey, 'session'); - return; - } - if (mode === 'local' && this.localStorageAvailable) { - localStorage.setItem(this.backendPreferenceKey, 'local'); - return; - } - } catch (_) { /* ignore */ } + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; } - clearBackendPreference() { - try { if (this.sessionStorageAvailable) { sessionStorage.removeItem(this.backendPreferenceKey); } } catch (_) {} - try { if (this.localStorageAvailable) { localStorage.removeItem(this.backendPreferenceKey); } } catch (_) {} + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); } - async initializeStorage() { - console.log('[Storage] 开始初始化存储系统'); - try { - this.localStorageAvailable = this.checkStorageAvailability(() => localStorage); - this.sessionStorageAvailable = this.checkStorageAvailability(() => sessionStorage); - if (this.localStorageAvailable) { - console.log('[Storage] localStorage 可用,将使用 localStorage 作为主要存储'); - this.setBackendPreference('local'); - } else { - console.warn('[Storage] localStorage 不可用'); - } - if (this.sessionStorageAvailable) { - console.log('[Storage] sessionStorage 可用,可作为退路'); - } else { - console.warn('[Storage] sessionStorage 不可用'); - } - - const storedPreference = this.getStoredBackendPreference(); - if (storedPreference === 'session') { - this.useSessionStorageFallback = true; - } - if (!this.localStorageAvailable && this.sessionStorageAvailable) { - this.useSessionStorageFallback = true; - } - - // 强制初始化 IndexedDB 以实现 Hybrid 模式,并在版本检查前确保 DB ready - console.log('[Storage] 强制初始化 IndexedDB 以实现 Hybrid 模式'); - await this.initializeIndexedDBStorage(); + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; - // 初始化版本信息 - const currentVersion = await this.get('system_version', null, { skipReady: true }); - console.log(`[Storage] 当前版本: ${currentVersion}, 目标版本: ${this.version}`); + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; - if (!currentVersion) { - // 首次安装 - console.log('[Storage] 首次安装,初始化默认数据'); - await this.handleVersionUpgrade(null, { skipReady: true }); - } else if (currentVersion !== this.version) { - // 版本升级 - console.log('[Storage] 版本升级,迁移数据'); - await this.handleVersionUpgrade(currentVersion, { skipReady: true }); - } else { - console.log('[Storage] 版本匹配,跳过初始化'); - } + if (demoSourceSet.has(readMetadataSource(record))) return false; - // 添加恢复逻辑 - } catch (error) { - console.warn('[Storage] 初始化基本存储能力失败,尝试继续:', error); - await this.initializeIndexedDBStorage(); - } + return true; } - /** - * 初始化IndexedDB存储 - */ - initializeIndexedDBStorage() { - console.log('[Storage] 开始初始化 IndexedDB'); - if (this.indexedDBBlocked) { - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - try { - // 检查IndexedDB支持 - if (!window.indexedDB) { - this.indexedDBBlocked = true; - this.indexedDB = null; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - this.volatileMode = false; - this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage'; - console.warn('[Storage] IndexedDB 不支持,将使用现有本地/会话存储'); - resolve(); - return; - } - this.volatileMode = true; - this.mode = 'volatile'; - console.warn('[Storage] IndexedDB 不支持且无本地存储,fallback 到内存存储'); - this.fallbackStorage = new Map(); - resolve(); - return; - } - - this.dbName = 'ExamSystemDB'; - this.dbVersion = 1; - - console.log(`[Storage] 打开 IndexedDB 数据库: ${this.dbName}, 版本: ${this.dbVersion}`); - const request = indexedDB.open(this.dbName, this.dbVersion); - request.addEventListener('error', () => { - this.indexedDB = null; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - this.volatileMode = false; - this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage'; - return; - } - this.volatileMode = true; - this.mode = 'volatile'; - this.fallbackStorage = this.fallbackStorage || new Map(); - }); - request.addEventListener('success', () => { - this.volatileMode = false; - this.mode = 'indexeddb'; - }); + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } - request.onerror = (event) => { - console.error('[Storage] IndexedDB 打开失败:', event.target.error); - this.indexedDBBlocked = true; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - console.warn('[Storage] 使用 local/sessionStorage 作为回退存储'); - this.indexedDB = null; - resolve(); - return; - } - this.fallbackStorage = new Map(); - resolve(); - }; + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } - request.onupgradeneeded = (event) => { - console.log('[Storage] IndexedDB 升级事件触发,旧版本:', event.oldVersion, '新版本:', event.newVersion); - const db = event.target.result; - - // 创建存储对象 - if (!db.objectStoreNames.contains('keyValueStore')) { - console.log('[Storage] 创建 objectStore: keyValueStore'); - const store = db.createObjectStore('keyValueStore', { keyPath: 'key' }); - store.createIndex('timestamp', 'timestamp', { unique: false }); - console.log('[Storage] objectStore 创建成功'); - } else { - console.log('[Storage] objectStore 已存在,跳过创建'); - } - }; + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); - request.onsuccess = (event) => { - this.indexedDB = event.target.result; - this.indexedDBBlocked = false; - console.log('[Storage] IndexedDB 初始化成功,数据库:', this.indexedDB.name, '版本:', this.indexedDB.version); - - // 迁移localStorage数据到IndexedDB - console.log('[Storage] 开始从 localStorage 迁移数据'); - Promise.resolve() - .then(() => this.migrateFromLocalStorage()) - .then(() => resolve()) - .catch((migrationError) => { - console.warn('[Storage] 迁移过程中出现问题,但继续初始化:', migrationError); - resolve(); - }); - }; + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } - } catch (error) { - console.error('[Storage] IndexedDB 初始化失败:', error); - this.indexedDBBlocked = true; - if (this.localStorageAvailable || this.sessionStorageAvailable) { - console.warn('[Storage] IndexedDB 初始化失败,将使用 local/sessionStorage'); - this.indexedDB = null; - resolve(); - return; - } - this.fallbackStorage = new Map(); - resolve(); - } - }); + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; } - /** - * 确保 IndexedDB 已 ready - */ - async ensureIndexedDBReady() { - if (this.indexedDBBlocked) { - return; - } - if (!this.indexedDB) { - try { - await this.initializeIndexedDBStorage(); - } catch (err) { - this.indexedDBBlocked = true; - } + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; } + return previewRecordIds.delete(normalizeId(recordId)); } - async tryPromoteToIndexedDB(serializedValue, key) { - try { - if (!this.indexedDB) { - await this.initializeIndexedDBStorage(); - } - if (this.indexedDB) { - await this.setToIndexedDB(this.getKey(key), serializedValue); - this.useSessionStorageFallback = false; - this.setBackendPreference('local'); - this.dispatchStorageSync(key); - return true; - } - } catch (e) { - console.warn('[Storage] 提升到 IndexedDB 失败,继续使用退路:', e); - } - return false; + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); } /** - * 从localStorage迁移数据到IndexedDB + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 */ - async migrateFromLocalStorage() { - console.log('[Storage] 开始数据迁移'); - try { - if (!this.indexedDB) { - console.warn('[Storage] IndexedDB 不可用,跳过迁移'); - return; - } + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); - const keys = Object.keys(localStorage); - const migrationKeys = keys.filter(key => key.startsWith(this.prefix)); - console.log(`[Storage] 发现 ${migrationKeys.length} 条需要迁移的键`); + global.PracticeRecordSource = api; - if (migrationKeys.length === 0) { - console.log('[Storage] 无数据需要迁移'); - return; - } + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); - let migratedCount = 0; - let failedCount = 0; - for (const key of migrationKeys) { - try { - const value = localStorage.getItem(key); - if (value) { - await this.setToIndexedDB(key, value); - localStorage.removeItem(key); - migratedCount++; - console.log(`[Storage] 成功迁移键: ${key}`); - } - } catch (error) { - console.warn(`[Storage] 迁移数据失败: ${key}`, error); - failedCount++; - } - } +/* ===== js/data/v2/dataCatalog.js ===== */ +(function installDataCatalog(global) { + 'use strict'; - console.log(`[Storage] 数据迁移完成: ${migratedCount} 成功, ${failedCount} 失败`); - } catch (error) { - console.error('[Storage] 数据迁移失败:', error); + const V2_SCHEMA_VERSION = 2; + + function clone(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } } + return JSON.parse(JSON.stringify(value)); } - /** - * 存储到IndexedDB - */ - setToIndexedDB(key, value) { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } + function objectDefault() { return {}; } + function arrayDefault() { return []; } + function nullableDefault() { return null; } + function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; } + function normalizeObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {}; + } + function normalizeNullableString(value) { + return value === null || value === undefined || value === '' ? null : String(value); + } + function isArray(value) { return Array.isArray(value); } + function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } + function isNullableString(value) { return value === null || typeof value === 'string'; } - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite'); - const store = transaction.objectStore('keyValueStore'); + const CATALOG_OWNERS = new Set([ + 'settings', 'library', 'recovery', 'backups', 'vocab', + 'preferences', 'goals', 'achievements', 'system', 'practice' + ]); + const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']); + const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']); - const data = { - key: key, - value: value, - timestamp: Date.now() - }; + function isNonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); + } - const request = store.put(data); + function ownerFromKey(logicalKey) { + const dot = String(logicalKey || '').indexOf('.'); + return dot > 0 ? logicalKey.slice(0, dot) : ''; + } - request.onsuccess = () => resolve(true); - request.onerror = () => reject(request.error); + function freezeEntry(entry) { + const logicalKey = String(entry.logicalKey || ''); + const owner = ownerFromKey(logicalKey); + const next = Object.assign({}, entry, { + logicalKey, + owner, + schemaVersion: V2_SCHEMA_VERSION, + export: entry.export === true, + import: entry.import || 'ignore' }); + return Object.freeze(next); } - /** - * 从IndexedDB获取数据 - */ - getFromIndexedDB(key) { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; + // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations), + // not as document keys. import merge identity is resolved in AppData, not here. + const definitions = [ + { + logicalKey: 'settings.values', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.configurations', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'library.importedIndexes', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.activeConfigurationId', classification: 'authoritative', + defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString, + export: true, import: 'replace' + }, + { + logicalKey: 'recovery.activeSessions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.drafts', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.interrupted', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.windowSession', classification: 'session', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.entries', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'merge-by-id' + }, + { + logicalKey: 'backups.settings', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'backups.exportHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.importHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'vocab.words', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'vocab.userConfig', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'vocab.lists', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'preferences.values', classification: 'preference', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'goals.items', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'achievements.manual', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'achievements.progress', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'system.migrations', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'system.operationJournal', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + } + ].map(freezeEntry); + + function validateCatalog(entries) { + if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry'); + const logicalKeys = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('DataCatalog entry must be an object'); + } + if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) { + throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`); + } + logicalKeys.add(entry.logicalKey); + } + for (const entry of entries) { + if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) { + throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`); + } + if (!CATALOG_CLASSIFICATIONS.has(entry.classification) + || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION + || typeof entry.defaultValue !== 'function' + || typeof entry.normalize !== 'function' + || typeof entry.validate !== 'function' + || typeof entry.export !== 'boolean' + || !IMPORT_POLICIES.has(entry.import)) { + throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`); } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly'); - const store = transaction.objectStore('keyValueStore'); - const request = store.get(key); - - request.onsuccess = () => { - if (request.result) { - resolve(request.result.value); - } else { - resolve(null); + try { + const defaultValue = entry.defaultValue(); + if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) { + throw new Error('invalid default'); } - }; - request.onerror = () => reject(request.error); - }); + } catch (_) { + throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`); + } + } + return true; } - /** - * 从IndexedDB删除数据 - */ - removeFromIndexedDB(key) { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } + validateCatalog(definitions); + const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry])); + const DataCatalog = Object.freeze({ + version: V2_SCHEMA_VERSION, + list() { return definitions.slice(); }, + get(logicalKey) { + const entry = byKey.get(String(logicalKey || '')); + if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`); + return entry; + }, + has(logicalKey) { return byKey.has(String(logicalKey || '')); }, + validate: validateCatalog, + clone + }); - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite'); - const store = transaction.objectStore('keyValueStore'); - const request = store.delete(key); + Object.defineProperty(global, '__AppDataV2Catalog', { + value: DataCatalog, + enumerable: false, + configurable: true, + writable: false + }); +})(typeof window !== 'undefined' ? window : globalThis); - request.onsuccess = () => resolve(true); - request.onerror = () => reject(request.error); - }); - } - /** - * 处理版本升级 - */ - async handleVersionUpgrade(oldVersion, options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - console.log(`Upgrading storage from ${oldVersion || 'unknown'} to ${this.version}`); +/* ===== js/data/v2/dataKernel.js ===== */ +(function installDataKernel(global) { + 'use strict'; - // 在这里处理数据迁移逻辑 - if (!oldVersion) { - // 首次安装,初始化默认数据 - await this.initializeDefaultData({ skipReady }); - } + if (global.AppData) return; + + const catalog = global.__AppDataV2Catalog; + if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel'); + + const DATABASE_NAME = 'IELTSAtlasDataV2'; + // Version 2 uses a new schema, but initialization must still import the durable + // ExamSystemDB data owned by releases which predate AppData v2. + const DATABASE_VERSION = 2; + const DOCUMENT_STORE = 'documents'; + const SYSTEM_STORE = 'system'; + const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES)); + const OPERATION_JOURNAL_WINDOW = 500; + const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`; + const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000; + const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000; + const MAX_TIMER_DELAY_MS = 2147483647; + const LEGACY_DATABASE_NAME = 'ExamSystemDB'; + const LEGACY_STORE_NAME = 'keyValueStore'; + const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup'; + const LEGACY_EXTERNAL_STORE_NAME = 'handles'; + const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory'; + const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json'; + const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([ + 'practice_records', + 'vocab_user_config', + 'user_achievements' + ]); - await this.set('system_version', this.version, { skipReady }); + function clone(value) { return catalog.clone(value); } + function nowIso() { return new Date().toISOString(); } + function randomId(prefix) { + const random = global.crypto && typeof global.crypto.randomUUID === 'function' + ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `${prefix || 'op'}_${random}`; + } - // 执行遗留数据迁移(只运行一次) - if (!await this.get('migration_completed', null, { skipReady })) { - console.log('[Storage] 检测到未完成迁移,开始执行...'); - await this.migrateLegacyData({ skipReady }); - } else { - console.log('[Storage] 迁移已完成,跳过'); + class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'AppDataError'; + this.code = code; + this.committed = false; + this.details = details; } } + function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); } + function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); } - /** - * 初始化默认数据 - */ - async initializeDefaultData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - const defaultData = { - settings: { - theme: 'light', - notifications: true, - autoSave: true, - reminderTime: '19:00' - }, - exam_index: null, - learning_goals: [] + function normalizeTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback; + } + function scheduleTimeout(handler, delayMs) { + if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable'); + const handle = global.setTimeout.call(global, handler, delayMs); + if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle'); + return handle; + } + function cancelTimeout(handle) { + if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') { + try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ } + } + } + function withDeadline(handle, timeoutMs, description, resolve, reject) { + let settled = false; + let timer = null; + const settle = (callback, value) => { + if (settled) return; + settled = true; + cancelTimeout(timer); + callback(value); }; - - for (const [key, value] of Object.entries(defaultData)) { - const existingValue = await this.get(key, null, { skipReady }); - if (existingValue === null || existingValue === undefined) { - console.log(`[Storage] 初始化默认数据: ${key}`); - await this.set(key, value, { skipReady }); - } else { - console.log(`[Storage] 保留现有数据: ${key} (${Array.isArray(existingValue) ? existingValue.length + ' 项' : typeof existingValue})`); - } + const expire = (error) => { + if (settled) return; + settled = true; + try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ } + reject(error); + }; + try { + timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, { + operation: description, timeoutMs, reason: 'timeout' + })), timeoutMs); + } catch (error) { + expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, { + operation: description, reason: 'watchdog-unavailable', cause: error && error.message + })); } + return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } }; } - /** - * 设置存储命名空间 - */ - setNamespace(namespace) { - if (typeof namespace === 'string' && namespace.trim()) { - this.prefix = namespace.trim() + '_'; - console.log('[Storage] 命名空间已设置为:', this.prefix); - } else { - console.warn('[Storage] 无效的命名空间:', namespace); + function canonicalizeJson(value, path = '$', ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path }); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') { + throw validation(`Non-JSON value at ${path}`, { path, type: typeof value }); } + if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path }); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path }); + if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw validation(`Symbol-keyed property at ${path}`, { path }); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item, index) => { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path }); + return canonicalizeJson(item, `${path}[${index}]`, ancestors); + }); + } + const result = {}; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path }); + result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors); + } + return result; + } finally { ancestors.delete(value); } } - - /** - * 生成完整的存储键名 - */ - getKey(key) { - return this.prefix + key; + function stableStringifyCanonical(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`; } - - createStoredEnvelope(value) { - const compressedValue = this.compressData(value); - return JSON.stringify({ - data: compressedValue, - timestamp: Date.now(), - version: this.version, - compressed: compressedValue !== value + function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); } + function checksum(value) { + const input = stableStringify(value); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + function legacyTimestamp(value) { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity; + if (Number.isFinite(Number(value))) return Number(value); + const parsed = Date.parse(value == null ? '' : String(value)); + return Number.isFinite(parsed) ? parsed : -Infinity; + } + function parseLegacyCandidate(value, outerTimestamp) { + let parsed = value; + let timestamp = legacyTimestamp(outerTimestamp); + const hasOuterTimestamp = timestamp !== -Infinity; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; } + } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') + && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) { + const innerTimestamp = legacyTimestamp(parsed.timestamp); + if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp; + parsed = parsed.data; + } else break; + } + return { value: clone(parsed), timestamp }; + } + function parseLegacyValue(value) { + const candidate = parseLegacyCandidate(value); + return candidate ? candidate.value : clone(value); + } + async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) { + const values = {}; + const candidates = {}; + let readComplete = true; + const consider = (alias, rawValue, timestamp, sourceRank) => { + const candidate = parseLegacyCandidate(rawValue, timestamp); + if (!candidate) return; + const previous = candidates[alias]; + if (!previous || candidate.timestamp > previous.timestamp + || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) { + candidates[alias] = Object.assign(candidate, { sourceRank }); + } + }; + if (indexedDBApi && typeof indexedDBApi.open === 'function') { + await new Promise((resolve) => { + let request; + let createdEmptyDatabase = false; + try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; } + request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); }; + request.onupgradeneeded = () => { + createdEmptyDatabase = true; + try { request.transaction.abort(); } catch (_) {} + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; } + const tx = db.transaction(LEGACY_STORE_NAME, 'readonly'); + const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys(); + const rows = tx.objectStore(LEGACY_STORE_NAME).getAll(); + tx.oncomplete = () => { + (keys.result || []).forEach((key, index) => { + const row = (rows.result || [])[index]; + // v1's keyValueStore persisted { key, value, timestamp } rows. + const validRow = row && typeof row === 'object' + && Object.prototype.hasOwnProperty.call(row, 'key') + && String(row.key) === String(key) + && Object.prototype.hasOwnProperty.call(row, 'value'); + if (!validRow) { + readComplete = false; + return; + } + consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0); + }); + db.close(); resolve(); + }; + tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); }; + }; + }); + } + for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) { + if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue; + for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) { + const key = fallbackStorage.key(index); + if (!key) continue; + const alias = key.startsWith('exam_system_') + ? key.slice('exam_system_'.length) + : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null); + if (!alias) continue; + try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ } + } + } + for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value; + Object.defineProperty(values, '__legacyReadComplete', { + value: readComplete, + enumerable: false, + configurable: false, + writable: false + }); + return values; + } + async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) { + if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null; + const directoryHandle = await new Promise((resolve) => { + let request; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value || null); + }; + try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; } + request.onerror = () => finish(null); + request.onupgradeneeded = () => { + try { request.transaction.abort(); } catch (_) {} + finish(null); + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) { + db.close(); finish(null); return; + } + const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly') + .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY); + get.onerror = () => { db.close(); finish(null); }; + get.onsuccess = () => { db.close(); finish(get.result); }; + }; }); + if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null; + if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null; + const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false }); + const parsed = JSON.parse(await (await fileHandle.getFile()).text()); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined + ? parsed.data : parsed; + return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null; + } + function lookupEntry(logicalKey) { + if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey }); + return catalog.get(logicalKey); + } + function storeFor(logicalKey) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey }); + return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE; + } + function makeEnvelope(entry, data, options = {}) { + const state = options.state === 'cleared' ? 'cleared' : 'present'; + if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`); + let normalized = null; + if (state === 'present') { + try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) { + throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message }); + } + normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`); + if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey }); + } + const revision = options.revision === undefined ? 1 : Number(options.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`); + const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')), + updatedAt: options.updatedAt || nowIso(), state, data: normalized }; + payload.checksum = checksum(payload.data); + return Object.freeze(payload); + } + function validateEnvelope(entry, envelope) { + try { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) + || Number(envelope.schemaVersion) !== Number(entry.schemaVersion) + || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1 + || typeof envelope.operationId !== 'string' || !envelope.operationId + || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt + || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false; + const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`); + return (envelope.state !== 'cleared' || data === null) + && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data); + } catch (_) { return false; } + } + function operationId(value) { + if (value === undefined || value === null || value === '') return randomId('mutation'); + if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string'); + return value; } - - parseStoredEnvelope(serializedValue, defaultValue = undefined) { - if (serializedValue === undefined || serializedValue === null) { - return defaultValue; + function expectedRevision(value, label) { + if (value === undefined || value === null) return null; + const revision = Number(value); + if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`); + return revision; + } + function compactJournal(journal) { + const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence)); + for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]]; + } + function readJournal(row) { + const envelope = row && row.envelope; + return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? clone(envelope.data) : {}; + } + function journalResult(journal, spec) { + const existing = journal[spec.operationId]; + if (!existing) return null; + if (existing.fingerprint !== spec.fingerprint || !existing.receipt) { + throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId }); + } + return clone(existing.receipt); + } + function writeJournal(journal, spec, receipt) { + const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1; + journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() }; + compactJournal(journal); + return journal; + } + function putJournal(tx, currentRow, journal, spec, receipt) { + const current = currentRow && currentRow.envelope; + const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), { + revision: current ? Number(current.revision) + 1 : 1, + operationId: spec.operationId, + normalized: true + }); + tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) }); + } + + class IndexedDBDriver { + constructor(indexedDBApi, options) { + this.indexedDB = indexedDBApi; + this.db = null; + this.mutationTimeoutMs = options.mutationTimeoutMs; + this.requestTimeoutMs = options.requestTimeoutMs; + } + async initialize() { + if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable'); + this.db = await new Promise((resolve, reject) => { + const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + let abandoned = false; + let settle; + request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion < 2) { + for (const name of ['authoritative', 'derived']) { + if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name); + } + } + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' }); + } + }; + settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject); + request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB')); + request.onblocked = () => { + abandoned = true; + settle.reject(new Error('IndexedDB upgrade blocked')); + }; + }); + this.db.onversionchange = () => this.close(); + return this; } - const parsed = JSON.parse(serializedValue); - return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data') - ? parsed.data - : defaultValue; - } - - readWebStorageValue(storage, storageKey) { - if (!storage || typeof storage.getItem !== 'function') { - return null; + close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} } + _open() { if (!this.db) throw new Error('IndexedDB connection closed'); } + _transaction(stores, mode, description, work, mutation = false) { + this._open(); + return new Promise((resolve, reject) => { + let failure = null; + let value; + let tx; + try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; } + const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject); + tx.oncomplete = () => settle.resolve(clone(value)); + tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); }; + tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`)); + const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} }; + try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); } + }); } - try { - return storage.getItem(storageKey); - } catch (_) { - return null; + readEnvelope(logicalKey) { + const store = storeFor(logicalKey); + return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(logicalKey); + request.onsuccess = () => done(request.result ? request.result.envelope : null); + request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`)); + }); } - } - - writeWebStorageValue(storage, storageKey, serializedValue) { - if (!storage || typeof storage.setItem !== 'function') { - return false; + readEntity(store, recordId) { + return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(recordId); + request.onsuccess = () => done(request.result || null); + request.onerror = () => fail(request.error || new Error('Entity read failed')); + }); } - try { - storage.setItem(storageKey, serializedValue); - return true; - } catch (_) { - return false; + readPracticeSnapshot(recordIds = null, options = {}) { + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES.slice(); + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean)); + return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => { + const result = Object.fromEntries(stores.map((store) => [store, []])); + let remaining = stores.length; + const finishStore = (store, rows) => { + result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || ''))); + remaining -= 1; + if (!remaining) done(result); + }; + for (const store of stores) { + const objectStore = tx.objectStore(store); + const request = requested && requested.size === 1 + ? objectStore.get(Array.from(requested)[0]) + : objectStore.getAll(); + request.onsuccess = () => { + const rows = requested && requested.size === 1 + ? (request.result ? [request.result] : []) + : request.result; + finishStore(store, rows); + }; + request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`)); + } + }); + } + listEntities(store) { + return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => { + const request = tx.objectStore(store).getAll(); + request.onsuccess = () => done(request.result || []); + request.onerror = () => fail(request.error || new Error('Entity list failed')); + }); + } + atomic(spec) { + return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => { + const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal'); + journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed')); + journalRequest.onsuccess = () => { + try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); } + }; + }, true); + } + exportSnapshot(envelopeKeys) { + return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => { + const result = { envelopes: {}, entities: {} }; + let remaining = STORE_NAMES.length; + for (const store of STORE_NAMES) { + const request = tx.objectStore(store).getAll(); + request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`)); + request.onsuccess = () => { + if (store === DOCUMENT_STORE || store === SYSTEM_STORE) { + for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope; + } else result.entities[store] = request.result || []; + remaining -= 1; + if (!remaining) done(result); + }; + } + }); } } - async writePersistentValue(key, value, options = {}) { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - throw new Error(`Storage.writePersistentValue(${key}) is internal-only`); + function entityStore(store) { + const value = String(store || ''); + if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value }); + return value; + } + function validateEntityRow(store, row) { + if (!row || typeof row !== 'object' || Array.isArray(row) + || typeof row.recordId !== 'string' || !row.recordId + || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1 + || typeof row.operationId !== 'string' || !row.operationId + || typeof row.updatedAt !== 'string' || !row.updatedAt) { + throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null }); + } + const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`); + if (row.checksum !== checksum(data)) { + throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId }); + } + return row; + } + function normalizeEntityOperation(operation, index) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`); + const type = String(operation.type || ''); + const store = entityStore(operation.store); + if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`); + const recordId = type === 'clear' ? null : String(operation.recordId || ''); + if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`); + const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null; + return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) }; + } + function receiptFor(operationIdValue, revisions, warnings, pending) { + const receipt = { committed: true, revisions, operationId: operationIdValue, + derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() }; + const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]]; + return receipt; + } + + class DataKernel { + constructor(options = {}) { + this.driver = null; + this.backend = null; + this.state = 'created'; + this.failure = null; + this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB; + this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS); + this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS); + this.committedListeners = new Set(); + this.commitChannel = null; + this.instanceId = randomId('kernel'); + this.ready = null; + } + _initializeCommitChannel() { + if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return; + try { + const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME); + channel.onmessage = (message) => { + const data = message && message.data; + if (!data || data.sourceInstanceId === this.instanceId + || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return; + this._dispatchCommitted({ + operationId: data.operationId, + targets: clone(data.targets), + receipt: data.receipt ? clone(data.receipt) : null, + remote: true + }); + }; + this.commitChannel = channel; + } catch (_) { + this.commitChannel = null; + } + } + _closeCommitChannel() { + const channel = this.commitChannel; + this.commitChannel = null; + try { if (channel) channel.close(); } catch (_) {} + } + initialize() { + if (this.ready) return this.ready; + this.state = 'initializing'; + this.ready = new IndexedDBDriver(this.indexedDB, { + mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs + }).initialize().then((driver) => { + this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this; + }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null; + throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); }); + return this.ready; + } + close() { + if (this.driver) this.driver.close(); + this.driver = null; this.backend = null; + this._closeCommitChannel(); + if (this.state !== 'failed') this.state = 'closed'; + } + _assertReady() { + if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message }); + if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized'); + } + _latch(error) { + if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message }); + this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel(); + return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message }); + } + onCommitted(listener) { + if (typeof listener !== 'function') throw validation('Committed listener must be a function'); + this.committedListeners.add(listener); return () => this.committedListeners.delete(listener); + } + _dispatchCommitted(event) { + if (!event || !this.committedListeners.size) return; + const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback); + schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} })); + } + _notifyCommitted(targets, receipt) { + if (!targets.length) return; + const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false }; + this._dispatchCommitted(event); + if (this.commitChannel) { + try { + this.commitChannel.postMessage({ + sourceInstanceId: this.instanceId, + operationId: event.operationId, + targets: event.targets, + receipt: event.receipt + }); + } catch (_) { /* cross-realm notification is best effort */ } + } + } + async getEnvelope(logicalKey) { + this._assertReady(); const entry = lookupEntry(logicalKey); + try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async read(logicalKey, options = {}) { + const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey); + const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data; + return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data); + } + _documentSpec(changes, options) { + if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes'); + const opId = operationId(options.operationId); const seen = new Set(); + const prepared = changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`); + const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey); + if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel'); + if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey); + const state = change.state === 'cleared' ? 'cleared' : 'present'; + if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`); + if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`); + let data = null; + if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); } + return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) }; + }); + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings }); + return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) }; } - const serializedValue = this.createStoredEnvelope(value); - const storageKey = this.getKey(key); - - if (this.indexedDB && !this.indexedDBBlocked) { - await this.setToIndexedDB(storageKey, serializedValue); - this.dispatchStorageSync(key); - return true; + async mutate(changes, options = {}) { + this._assertReady(); const spec = this._documentSpec(changes, options); + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) })); + let remaining = reads.length; + const finish = () => { + const revisions = {}; + for (const item of reads) { + const current = item.request.result ? item.request.result.envelope : null; + if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey }); + const revision = current ? Number(current.revision) : 0; + if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision }); + const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true }); + tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision; + } + const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []); + putJournal(tx, journalRow, journal, spec, receipt); + done(receipt); + }; + if (!remaining) { finish(); return; } + for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification })); + this._notifyCommitted(targets, receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); } + async readEntity(store, recordId, options = {}) { + this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId'); + try { + const row = await this.driver.readEntity(store, id); + if (!row) return null; + validateEntityRow(store, row); + return options.withMeta ? clone(row) : clone(row.data); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async readPracticeSnapshot(recordIds = null, options = {}) { + this._assertReady(); + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + try { + const snapshot = await this.driver.readPracticeSnapshot(ids, options); + const result = {}; + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES; + for (const store of stores) { + const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : []) + .filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + result[store] = options.withMeta + ? clone(validRows) + : validRows.map((row) => clone(row.data)); + } + return result; + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } } - - if (this.localStorageAvailable && this.writeWebStorageValue(localStorage, storageKey, serializedValue)) { - this.mode = 'localStorage'; - this.volatileMode = false; - this.dispatchStorageSync(key); - return true; + async listEntities(store, options = {}) { + this._assertReady(); store = entityStore(store); + if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId'); + try { + const rows = await this.driver.listEntities(store); + const validRows = rows.filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data)); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } } - - if (this.sessionStorageAvailable && this.writeWebStorageValue(sessionStorage, storageKey, serializedValue)) { - this.mode = 'sessionStorage'; - this.volatileMode = false; - this.dispatchStorageSync(key); - return true; + async mutateEntities(operations, options = {}) { + this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations'); + const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set(); + for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); } + for (const store of ENTITY_STORES) { + const scoped = items.filter((item) => item.store === store); + if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) { + throw validation(`Entity clear cannot be combined with other operations for ${store}`); + } + } + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length; + const finish = () => { const revisions = {}; + for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0; + if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`); + const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; } + } + for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; } + const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); }; + if (!remaining) { try { finish(); } catch (error) { fail(error); } return; } + for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async exportSnapshot(options = {}) { + this._assertReady(); + try { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null; + const shouldExport = (logicalKey) => { + if (!catalog.has(logicalKey)) return false; + const entry = lookupEntry(logicalKey); + if (selected && !selected.has(logicalKey)) return false; + if (entry.export === true) return true; + return options.includeSystem === true && entry.classification === 'system'; + }; + const data = await this.driver.exportSnapshot(shouldExport); + // Full/partial snapshots must be dense for their declared catalog + // range. An absent physical row means the catalog default, not an + // instruction that future importers should guess about. + for (const entry of catalog.list()) { + if (!shouldExport(entry.logicalKey) + || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue; + data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, { + state: 'cleared', + operationId: 'snapshot-default' + }); + } + if (Array.isArray(options.entityStores)) { + const selectedStores = new Set(options.entityStores.map(entityStore)); + for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store]; + } + const payload = { envelopes: data.envelopes, entities: data.entities }; + return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) }; + } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async installSnapshot(snapshot, options = {}) { + this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} }; + const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities'); + if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid'); + if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch'); + const changes = []; + for (const [logicalKey, envelope] of Object.entries(envelopes)) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue; + if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`); + changes.push({ logicalKey, entry, envelope }); + } + const entityRows = {}; + for (const store of ENTITY_STORES) { + if (!Object.prototype.hasOwnProperty.call(entities, store)) continue; + const rows = entities[store]; + if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`); + const ids = new Set(); + entityRows[store] = rows.map((row) => { + if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`); + const recordId = String(row.recordId); + if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`); + ids.add(recordId); + const data = canonicalizeJson(row.data); + if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`); + const revision = row.revision === undefined ? 1 : Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`); + return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) }; + }); + } + if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data'); + const resetJournal = options.resetJournal === true; + const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object' + ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken') + : null; + const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {}; + const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {}; + const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({ + kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey) + })).concat(Object.entries(entityChecks).map(([store, expected]) => ({ + kind: 'entities', store, expected, request: tx.objectStore(store).getAll() + }))); + const finish = () => { + for (const read of reads) { + if (read.kind === 'document') { + const current = read.request.result ? read.request.result.envelope : null; + const actualRevision = current ? Number(current.revision) : 0; + const expectedRevision = Number(read.expected) || 0; + if (actualRevision !== expectedRevision) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision }); + } + } else { + const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0])); + const expected = read.expected && typeof read.expected === 'object' ? read.expected : {}; + const ids = new Set(Object.keys(actual).concat(Object.keys(expected))); + for (const recordId of ids) { + const current = actual[recordId] || 0; + const wanted = Number(expected[recordId]) || 0; + if (current !== wanted) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId }); + } + } + } + } + const revisions = {}; + for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); } + for (const [store, rows] of Object.entries(entityRows)) { + tx.objectStore(store).clear(); + for (const row of rows) tx.objectStore(store).put(row); + } + const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt); + }; + if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; } + let remaining = reads.length; + for (const read of reads) { + read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed')); + read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; + } + } })); + const targets = changes + .filter((item) => item.entry.owner !== 'backups') + .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification })) + .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' }))); + this._notifyCommitted(targets, receipt); + return receipt; + } + catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } } + status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); } + } - if (this.fallbackStorage) { - this.fallbackStorage.set(storageKey, serializedValue); - this.dispatchStorageSync(key); - return true; - } + Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false }); +})(typeof window !== 'undefined' ? window : globalThis); - this.volatileMode = true; - this.mode = 'volatile'; - this.fallbackStorage = this.fallbackStorage || new Map(); - this.fallbackStorage.set(storageKey, serializedValue); - this.dispatchStorageSync(key); - return true; - } - async readPersistentValue(key, defaultValue = undefined, options = {}) { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - throw new Error(`Storage.readPersistentValue(${key}) is internal-only`); - } - const storageKey = this.getKey(key); +/* ===== js/data/v2/appData.js ===== */ +(function installAppData(global) { + 'use strict'; - if (this.fallbackStorage && this.fallbackStorage.has(storageKey)) { - return this.parseStoredEnvelope(this.fallbackStorage.get(storageKey), defaultValue); - } + const internals = global.__AppDataV2Internals; + if (!internals || typeof internals.DataKernel !== 'function') { + throw new Error('AppData v2 requires DataKernel'); + } + const { + DataKernel, + AppDataError, + catalog, + clone, + randomId, + nowIso, + checksum + } = internals; + const kernel = new DataKernel(); + const importPlans = new Map(); + const RECOVERY_KEYS = Object.freeze({ + activeSession: 'recovery.activeSessions', + draft: 'recovery.drafts', + interrupted: 'recovery.interrupted', + rejectedCompletion: 'recovery.rejectedCompletions' + }); + const PREFERENCE_FIELDS = Object.freeze({ + theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode', + resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay', + threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget', + consent: 'consent', logConfig: 'logConfig' + }); + const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); - if (this.indexedDB && !this.indexedDBBlocked) { - const serializedValue = await this.getFromIndexedDB(storageKey); - return this.parseStoredEnvelope(serializedValue, defaultValue); + function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function asArray(value) { return Array.isArray(value) ? value : []; } + function idOf(value, fields) { + for (const field of fields) { + if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]); } - - if (this.localStorageAvailable) { - return this.parseStoredEnvelope(this.readWebStorageValue(localStorage, storageKey), defaultValue); + return ''; + } + function importedLibraryId(value, options = {}) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id && options.nullable) return null; + if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required'); + if (/^exam_index(?:_|$)/.test(id)) { + throw new AppDataError('VALIDATION', 'Unsupported library configuration id'); } - - if (this.sessionStorageAvailable) { - return this.parseStoredEnvelope(this.readWebStorageValue(sessionStorage, storageKey), defaultValue); + return id; + } + function assertObject(value, message) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function assertArray(value, message) { + if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function jsonValue(value, label = 'value') { + try { + const serialized = JSON.stringify(value, (_key, current) => { + if (typeof current === 'bigint') return String(current); + if (typeof current === 'number' && !Number.isFinite(current)) return null; + return current; + }); + if (serialized === undefined) return null; + return JSON.parse(serialized); + } catch (error) { + throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message }); } - - return defaultValue; } - - async removePersistentValue(key, options = {}) { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - throw new Error(`Storage.removePersistentValue(${key}) is internal-only`); + function operationId(command, prefix, semanticPayload = command) { + const id = command && command.operationId ? String(command.operationId) : randomId(prefix); + jsonValue(semanticPayload, `${prefix} payload`); + return id; + } + function mutationOptions(command, prefix, semanticPayload, extra = {}) { + const source = asObject(command); + const payload = jsonValue(semanticPayload, `${prefix} payload`); + const intent = { command: prefix, payload }; + if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) { + intent.expectedRevision = source.expectedRevision; } - const storageKey = this.getKey(key); - - if (this.fallbackStorage) { - this.fallbackStorage.delete(storageKey); + return Object.assign({}, extra, { + operationId: operationId(source, prefix, payload), + intent + }); + } + function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) { + return mutationOptions(asObject(options), prefix, semanticPayload, extra); + } + function deterministicEntityId(prefix, operation) { + return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`; + } + function normalizeAccuracyRatio(value, label = 'accuracy') { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`); } + return numeric > 1 ? numeric / 100 : numeric; + } + function defaultStats() { + return { + totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0, + reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + lastUpdated: nowIso() + }; + } - if (this.indexedDB && !this.indexedDBBlocked) { - await this.removeFromIndexedDB(storageKey); + function nonNegativeScalar(value) { + if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + } + + function firstNonNegativeScalar(...values) { + for (const value of values) { + const numeric = nonNegativeScalar(value); + if (numeric !== null) return numeric; } + return null; + } - try { localStorage.removeItem(storageKey); } catch (_) { } - try { sessionStorage.removeItem(storageKey); } catch (_) { } - this.dispatchStorageSync(key); - return true; + function normalizeAnswerQuestionId(value, index = 0) { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || `q${index + 1}`; } - async clearPersistentStorage(options = {}) { - if (!hasInternalAccessOptions(options)) { - throw new Error('Storage.clearPersistentStorage is internal-only'); - } - if (this.fallbackStorage) { - this.fallbackStorage.clear(); + function normalizeAnswerValue(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); + if (value && typeof value === 'object') { + if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); + if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); + if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); + return jsonValue(value, 'practice answer'); } + return typeof value === 'string' ? value : String(value); + } - if (this.indexedDB && !this.indexedDBBlocked) { - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite'); - const store = transaction.objectStore('keyValueStore'); - const request = store.clear(); - await new Promise((resolve, reject) => { - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + function normalizeAnswerMap(value) { + const normalized = {}; + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry === undefined || entry === null) return; + const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; + const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); + normalized[questionId] = normalizeAnswerValue( + hasOwn(item, 'answer') ? item.answer + : hasOwn(item, 'userAnswer') ? item.userAnswer + : hasOwn(item, 'value') ? item.value + : entry + ); }); + return normalized; } - - try { - Object.keys(localStorage) - .filter((key) => key.startsWith(this.prefix)) - .forEach((key) => localStorage.removeItem(key)); - } catch (_) { } - try { - Object.keys(sessionStorage) - .filter((key) => key.startsWith(this.prefix)) - .forEach((key) => sessionStorage.removeItem(key)); - } catch (_) { } - - this.clearBackendPreference(); - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key: '*' } })); - return true; + if (!value || typeof value !== 'object') return normalized; + Object.entries(value).forEach(([questionId, answer], index) => { + if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; + normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); + }); + return normalized; } - /** - * 压缩数据 - */ - compressData(data) { - try { - // 切记:不要压缩数组,避免把列表写坏 - if (Array.isArray(data)) { - return data; - } - // 仅对体积较大的“对象记录”压缩 - if (data && typeof data === 'object') { - const len = JSON.stringify(data).length; - if (len > 1000) { - return this.compressObject(data); - } + function mergeAnswerMaps(...sources) { + const merged = {}; + for (const source of sources) { + const normalized = normalizeAnswerMap(source); + for (const [questionId, answer] of Object.entries(normalized)) { + if (!hasOwn(merged, questionId)) merged[questionId] = answer; } - return data; - } catch (error) { - console.warn('[Storage] 数据压缩失败,使用原始数据:', error); - return data; } + return merged; } - /** - * 压缩对象数据 - */ - compressObject(obj) { - // 只保留核心字段:用户答案、canonical 正确答案表、正误、得分、正确率、答题时长、答题时间 - const coreFields = [ - 'id', 'examId', 'title', 'category', 'frequency', - 'score', 'totalQuestions', 'correctAnswers', 'correctAnswerMap', 'accuracy', 'percentage', 'duration', - 'startTime', 'endTime', 'date', 'sessionId', 'timestamp', - 'dataSource', 'realData' + function canonicalizeAnswerSource(record) { + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const rawRealData = asObject(rawData.realData); + record.answers = mergeAnswerMaps( + record.answers, + record.answerMap, + record.answerList, + realData.answers, + realData.answerMap, + rawData.answers, + rawData.answerMap, + rawRealData.answers + ); + // These names remain accepted only at the compatibility boundary. The + // canonical detail layer owns one user-answer source: `answers`. + delete record.answerMap; + delete record.answerList; + } + + function normalizePracticeScoreFields(record) { + const scoreInfo = asObject(record.scoreInfo); + const realScoreInfo = asObject(asObject(record.realData).scoreInfo); + const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); + const overloadedCorrectAnswers = record.correctAnswers; + const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; + if (isAnswerMap) { + const existingMap = record.correctAnswerMap; + const overloadedObject = asObject(overloadedCorrectAnswers); + const existingObject = asObject(existingMap); + if (Object.keys(overloadedObject).length) { + record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); + } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { + record.correctAnswerMap = clone(overloadedCorrectAnswers); + } + } + + let correctAnswers = firstNonNegativeScalar( + overloadedCorrectAnswers, + record.correctAnswersCount, + record.correctCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct, + rawScoreInfo.correctAnswers, + rawScoreInfo.correct + ); + if (correctAnswers === null) { + const comparisons = asArray(record.answerComparison).length + ? asArray(record.answerComparison) + : asArray(asObject(record.realData).answerComparison); + if (comparisons.length) { + correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; + } + } + if (correctAnswers !== null) record.correctAnswers = correctAnswers; + else if (isAnswerMap) record.correctAnswers = 0; + + const totalQuestions = firstNonNegativeScalar( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total, + rawScoreInfo.totalQuestions, + rawScoreInfo.total + ); + if (totalQuestions !== null) record.totalQuestions = totalQuestions; + } + + function canonicalizeRecord(input) { + assertObject(input, 'practice record must be an object'); + const record = jsonValue(input, 'practice record'); + record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record'); + record.sessionId = idOf(record, ['sessionId']) || record.id; + record.timestamp = record.timestamp || record.completedAt || record.date || nowIso(); + record.completedAt = record.completedAt || record.timestamp; + record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice'; + record.metadata = asObject(record.metadata); + if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; + if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; + canonicalizeAnswerSource(record); + normalizePracticeScoreFields(record); + for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { + if (record[field] === undefined || record[field] === null || record[field] === '') continue; + const numeric = Number(record[field]); + if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`); + record[field] = numeric; + } + if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy'); + return jsonValue(record, 'canonical practice record'); + } + + function deriveQuestionTypeErrorCounts(source) { + const record = asObject(source); + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const counts = {}; + const addCount = (type, value) => { + const key = String(type || 'other').trim() || 'other'; + const amount = Math.max(0, Number(value) || 0); + if (amount > 0) counts[key] = (counts[key] || 0) + amount; + }; + const performanceSources = [ + record.questionTypePerformance, + realData.questionTypePerformance, + rawData.questionTypePerformance ]; - - const compressed = {}; - - // 只保留核心字段 - coreFields.forEach(field => { - if (obj.hasOwnProperty(field)) { - compressed[field] = obj[field]; + let hasPerformanceData = false; + for (const performanceMap of performanceSources) { + if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; + let sourceHasPerformanceData = false; + for (const [type, value] of Object.entries(performanceMap)) { + const performance = asObject(value); + const total = Number(performance.total ?? performance.totalQuestions); + const correct = Number(performance.correct ?? performance.correctAnswers); + if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; + hasPerformanceData = true; + sourceHasPerformanceData = true; + addCount(type, total - correct); + } + if (sourceHasPerformanceData) break; + } + if (hasPerformanceData) return counts; + + const questionTypeMap = Object.assign( + {}, + asObject(rawData.questionTypeMap), + asObject(realData.questionTypeMap), + asObject(record.questionTypeMap) + ); + const normalizedTypeMap = {}; + for (const [questionId, type] of Object.entries(questionTypeMap)) { + normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; + } + const detailSources = [ + record.answerDetails, + asObject(record.scoreInfo).details, + realData.answerDetails, + asObject(realData.scoreInfo).details, + rawData.answerDetails, + asObject(rawData.scoreInfo).details + ]; + const seenQuestions = new Set(); + for (const details of detailSources) { + if (!details || typeof details !== 'object' || Array.isArray(details)) continue; + for (const [questionId, value] of Object.entries(details)) { + const detail = asObject(value); + const normalizedId = String(questionId).trim().toLowerCase(); + if (!normalizedId || seenQuestions.has(normalizedId)) continue; + let isWrong = detail.isCorrect === false || detail.correct === false; + if (detail.isCorrect === true || detail.correct === true) isWrong = false; + else if (!isWrong) { + const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); + const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); + isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); + } + if (!isWrong) continue; + seenQuestions.add(normalizedId); + addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); } - }); - - // 压缩realData,只保留核心内容 - if (obj.realData) { - compressed.realData = this.compressRealData(obj.realData); } - - return compressed; + return counts; } - /** - * 合并记录数组,避免重复 - * 基于 id 去重,保留最新的记录(按 updatedAt/createdAt/endTime/startTime 多字段回退) - */ - mergeRecords(current, legacy) { - if (!Array.isArray(current)) current = []; - if (!Array.isArray(legacy)) return current; - - // canonical 记录的主要时间字段是 updatedAt/createdAt/endTime/startTime, - // 不保证有顶层 timestamp。用多字段回退取最大时间戳,避免保留旧副本丢新副本。 - const resolveTimestamp = (record) => { - if (!record || typeof record !== 'object') return 0; - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.date, - record.timestamp - ]; - for (let i = 0; i < candidates.length; i += 1) { - const value = candidates[i]; - if (!value) continue; - const time = new Date(value).getTime(); - if (Number.isFinite(time)) return time; - } - return 0; - }; + function lightSuiteEntry(source, fallbackType = null) { + const entry = asObject(source); + const scoreInfo = asObject(entry.scoreInfo); + const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); + const metadata = asObject(entry.metadata); + const totalQuestions = firstNonNegativeScalar( + entry.totalQuestions, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total + ) ?? 0; + const correctAnswers = firstNonNegativeScalar( + entry.correctAnswers, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct + ) ?? 0; + const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'suite entry accuracy' + ) || 0; + const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0; + return jsonValue({ + id: entry.id || null, + sessionId: entry.sessionId || null, + examId: entry.examId || metadata.examId || null, + title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', + type: entry.type || metadata.type || metadata.examType || fallbackType || null, + date: entry.date || entry.completedAt || entry.timestamp || null, + duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage, + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + }, 'suite entry light projection'); + } + + function lightFromCanonical(source) { + const scoreInfo = asObject(source.scoreInfo); + const realScoreInfo = asObject(asObject(source.realData).scoreInfo); + const metadata = asObject(source.metadata); + const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const dataSource = hasOwn(source, 'dataSource') + ? source.dataSource + : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined); + const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0; + const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0; + const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'practice light accuracy' + ) || 0; + return jsonValue({ + id: source.id, + sessionId: source.sessionId, + examId: source.examId || source.metadata.examId || null, + title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '', + type: source.type, + mode: source.mode || source.practiceMode || null, + timestamp: source.timestamp, + completedAt: source.completedAt, + date: source.date || source.completedAt || source.timestamp || null, + startTime: source.startTime || null, + endTime: source.endTime || null, + duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, + score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` + // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 + // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 + dataSource, + // Summaries are list indexes. Keep only the metadata needed to filter, show a + // source label, or locate the originating library; details stay in their entity. + metadata: Object.fromEntries([ + // `source` must stay: PracticeRecordSource uses metadata.source demo markers + // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full. + 'examId', 'examTitle', 'title', 'type', 'category', 'frequency', + 'dataSource', 'source', 'libraryConfigurationId' + ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), + suite: source.suite == null ? null : clone(asObject(source.suite)), + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + }, 'practice light projection'); + } + + function projectLight(record) { + if (!record) return null; + return lightFromCanonical(canonicalizeRecord(record)); + } + + function firstNonEmpty(...values) { + let first; + for (const value of values) { + if (value === undefined || value === null) continue; + if (first === undefined) first = value; + if (Array.isArray(value) && value.length) return clone(value); + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value); + if (typeof value !== 'object') return clone(value); + } + return first === undefined ? {} : clone(first); + } + + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); + + function withoutRawData(value) { + if (Array.isArray(value)) return value.map(withoutRawData); + if (!value || typeof value !== 'object') return clone(value); + const clean = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item); + } + return clean; + } + + function splitPracticeRecord(input) { + const source = canonicalizeRecord(input); + const summary = lightFromCanonical(source); + const detail = { recordId: source.id }; + const annotations = { recordId: source.id }; + for (const [key, value] of Object.entries(source)) { + if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); + else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { + const next = Object.assign({}, asObject(entry)); + canonicalizeAnswerSource(next); + const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); + for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); + } + const annotation = {}; + for (const annotationKey of ANNOTATION_FIELDS) { + if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; } + if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey]; + if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey]; + } + delete next.realData; delete next.rawData; + if (Object.keys(annotation).length) { + if (!annotations.suiteEntries) annotations.suiteEntries = {}; + annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation; + } + return withoutRawData(next); + }); + else detail[key] = withoutRawData(value); + } + // Accept the old mirror only as an input normalization boundary; it is never persisted. + const realData = asObject(source.realData); const rawData = asObject(source.rawData); + for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); + } + for (const key of ANNOTATION_FIELDS) { + if (hasOwn(annotations, key)) continue; + if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]); + else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]); + } + return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') }; + } + + function joinPracticeRecord(summary, detail, annotations, projection = 'full') { + if (!summary) return null; + const mode = String(projection || 'full').toLowerCase(); + const light = clone(summary); + if (mode === 'light' || mode === 'summary') return light; + const joined = Object.assign({}, light, clone(asObject(detail))); + delete joined.recordId; + if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection'); + const annotationData = asObject(annotations); + for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value); + if (Array.isArray(joined.suiteEntries)) { + const suiteAnnotations = asObject(annotationData.suiteEntries); + joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {}))); + } + return jsonValue(joined, 'practice full projection'); + } + + function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); } + + // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。 + // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定, + // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败, + // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。 + const practiceRecordSource = global.PracticeRecordSource; + if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') { + throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)'); + } + const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord; + + function computeStats(records) { + const stats = defaultStats(); + for (const record of asArray(records).filter(isRealPracticeRecord)) { + const summary = projectLight(record); + const type = String(summary.type || '').toLowerCase(); + const target = type.includes('listen') ? stats.listening : stats.reading; + stats.totalPractices += 1; + stats.totalQuestions += summary.totalQuestions; + stats.correctAnswers += summary.correctAnswers; + target.practices += 1; + target.questions += summary.totalQuestions; + target.correct += summary.correctAnswers; + } + stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0; + for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0; + stats.lastUpdated = nowIso(); + return stats; + } - const mergedMap = new Map(); - [...current, ...legacy].forEach(record => { - if (record && record.id) { - const existing = mergedMap.get(record.id); - if (!existing || (resolveTimestamp(record) > resolveTimestamp(existing))) { - mergedMap.set(record.id, record); - } - } else if (record && record.timestamp) { - // 如果无 id,使用 timestamp 过滤 - mergedMap.set(record.timestamp, record); - } - }); + function validIso(value) { + if (value === null || value === undefined || value === '') return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } - return Array.from(mergedMap.values()).sort((a, b) => resolveTimestamp(b) - resolveTimestamp(a)); + function practiceType(record) { + const metadata = asObject(record.metadata); + const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType, + record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase(); + if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening'; + if (hints.includes('read')) return 'reading'; + return null; } - async listPracticeRecordsCanonical(options = {}) { - const { skipReady = false } = options; + function accuracyRatio(record) { + const summary = lightFromCanonical(record); + const value = Number(summary.accuracy); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } - const api = await this.getPracticeRecordAPI({ skipReady }); - if (api && typeof api.list === 'function') { - const records = await api.list(); - return Array.isArray(records) ? records : []; + function durationSeconds(record) { + const scoreInfo = asObject(record.scoreInfo); + const realData = asObject(record.realData); + const realScoreInfo = asObject(realData.scoreInfo); + for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } - - throw new Error('Storage.listPracticeRecordsCanonical: unified store not ready'); + return 0; } - async replacePracticeRecordsCanonical(records, options = {}) { - const { skipReady = false, updateStats } = options; - if (!Array.isArray(records)) { - throw new Error('Storage.replacePracticeRecordsCanonical requires an array of records'); + function earlierUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function laterUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function computeAchievementProgress(records, manual, existing) { + const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord) + .map((record, index) => ({ + record, + index, + unlockedAt: validIso(record.completedAt || record.timestamp), + time: new Date(record.completedAt || record.timestamp).getTime() + })) + .sort((left, right) => { + const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER; + const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER; + return leftTime - rightTime || left.index - right.index; + }); + const candidates = {}; + const setThreshold = (id, list, count) => { + if (list.length >= count) candidates[id] = list[count - 1].unlockedAt; + }; + setThreshold('first_step', items, 1); + setThreshold('practice_bronze', items, 10); + setThreshold('practice_silver', items, 50); + setThreshold('practice_gold', items, 100); + setThreshold('practice_platinum', items, 200); + + const reading = items.filter((item) => practiceType(item.record) === 'reading'); + const listening = items.filter((item) => practiceType(item.record) === 'listening'); + setThreshold('reading_first', reading, 1); + setThreshold('reading_bronze', reading, 10); + setThreshold('reading_silver', reading, 50); + setThreshold('reading_gold', reading, 100); + setThreshold('listening_first', listening, 1); + setThreshold('listening_bronze', listening, 10); + setThreshold('listening_silver', listening, 50); + setThreshold('listening_gold', listening, 100); + if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt); + if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt); + + let cumulativeDuration = 0; + let cumulativeAccuracy = 0; + let perfectCount = 0; + let speedCount = 0; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const accuracy = accuracyRatio(item.record); + const duration = durationSeconds(item.record); + cumulativeDuration += duration; + cumulativeAccuracy += accuracy; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt; + if (accuracy >= 1) { + perfectCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt; + if (perfectCount === 3) candidates.perfect_three = item.unlockedAt; + if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt; + } + if (duration > 0 && duration <= 300 && accuracy > 0.8) { + speedCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt; + if (speedCount === 3) candidates.speed_three = item.unlockedAt; + if (speedCount === 10) candidates.speed_ten = item.unlockedAt; + } + } + + const dayItems = new Map(); + for (const item of items) { + if (!item.unlockedAt) continue; + const day = item.unlockedAt.slice(0, 10); + if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt); + } + const days = Array.from(dayItems.keys()).sort(); + let streak = 0; + let previousDay = null; + for (const day of days) { + const currentDay = new Date(`${day}T00:00:00.000Z`).getTime(); + streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1; + previousDay = currentDay; + if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day); + if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day); + if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day); + if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day); + } + + const progress = {}; + const mergeUnlocked = (source) => { + for (const [rawId, value] of Object.entries(asObject(source))) { + if (!value || rawId === 'updatedAt') continue; + const id = rawId; + const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null; + if (!progress[id]) progress[id] = { unlockedAt }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + }; + mergeUnlocked(existing); + mergeUnlocked(manual); + for (const [id, unlockedAt] of Object.entries(candidates)) { + if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); } + return jsonValue(progress, 'achievement progress'); + } - const api = await this.getPracticeRecordAPI({ skipReady }); - if (api && typeof api.replace === 'function') { - // 透传 updateStats 选项:导入/回滚场景同时写入 user_stats, - // 若此处 recalculateStats 会和并发 writeUserStatsCanonical 竞争,谁后写谁生效。 - // 默认 undefined 让 api.replace 自行决定(保存路径会重算),导入路径传 false 跳过。 - await api.replace(records, { maxRecords: 1000, updateStats }); - return true; + // Entity records are authoritative. Projections are assembled on reads, never cached or + // scheduled as follow-up work; this keeps a successful write immediately observable. + async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } + + async function retryMergeConflict(options, task, maxAttempts = 3) { + const explicitRevision = hasOwn(options, 'expectedRevision'); + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) { + throw error; + } + } } + throw lastError; + } - throw new Error('Storage.replacePracticeRecordsCanonical: unified store not ready'); + async function readCollectionMeta(logicalKey) { + const meta = await kernel.read(logicalKey, { withMeta: true }); + return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 }; } - async writeUserStatsCanonical(stats, options = {}) { - const { skipReady = false } = options; - const api = await this.getPracticeRecordAPI({ skipReady }); - if (api && typeof api.writeStats === 'function') { - return await api.writeStats(stats); + function retainBackupEntries(items, limit = 20, preserveIds = []) { + const cap = Math.max(1, Number(limit) || 20); + const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || '')); + const entries = asArray(items).filter(Boolean).sort(newestFirst); + const retained = []; + const retainedIds = new Set(); + const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean)); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); } - throw new Error('Storage.writeUserStatsCanonical: unified stats store not ready'); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap) break; + if (retainedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + return retained; } - async mergePracticeRecordsCanonical(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('Storage.mergePracticeRecordsCanonical requires an array of records'); - } - const current = await this.listPracticeRecordsCanonical(options); - const merged = this.mergeRecords(current, records); - await this.replacePracticeRecordsCanonical(merged, options); - return merged; + function hasOwn(value, key) { + return Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); } - /** - * 压缩realData数据 - */ - compressRealData(realData) { - const compressed = { - score: realData.score, - totalQuestions: realData.totalQuestions, - accuracy: realData.accuracy, - percentage: realData.percentage, - duration: realData.duration, - answers: realData.answers || {}, - correctAnswerMap: realData.correctAnswerMap || {}, - isRealData: realData.isRealData, - source: realData.source - }; + function normalizeLibraryConfigurationId(value) { + return importedLibraryId(value, { nullable: true }); + } - // 压缩答案历史,只保留每个题目的最后一次答案 - if (realData.answerHistory) { - const latestAnswers = {}; - Object.entries(realData.answerHistory).forEach(([questionId, history]) => { - if (Array.isArray(history) && history.length > 0) { - latestAnswers[questionId] = history[history.length - 1]; - } - }); - compressed.answerHistory = latestAnswers; - } + async function practiceRecordWithLibraryProvenance(source, command, options = {}) { + assertObject(source, 'practice record must be an object'); + const record = jsonValue(source, 'practice record'); + const metadata = asObject(record.metadata); + let configurationId; - // 压缩交互记录,只保留最近50次 - if (realData.interactions && Array.isArray(realData.interactions)) { - compressed.interactions = realData.interactions.slice(-50); + if (hasOwn(command, 'libraryConfigurationId')) { + configurationId = command.libraryConfigurationId; + } else if (hasOwn(metadata, 'libraryConfigurationId')) { + configurationId = metadata.libraryConfigurationId; + } else if (hasOwn(record, 'libraryConfigurationId')) { + configurationId = record.libraryConfigurationId; + } else { + configurationId = await kernel.read('library.activeConfigurationId'); } - // 压缩详细的题目比较信息 - if (realData.answerComparison) { - const simplifiedComparison = {}; - Object.entries(realData.answerComparison).forEach(([questionId, comparison]) => { - simplifiedComparison[questionId] = { - userAnswer: comparison.userAnswer || '', - isCorrect: typeof comparison.isCorrect === 'boolean' ? comparison.isCorrect : null - }; + const normalizedId = normalizeLibraryConfigurationId(configurationId); + record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId }); + + if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) { + record.suiteEntries = record.suiteEntries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const next = jsonValue(entry, 'practice suite entry'); + const entryMetadata = asObject(next.metadata); + const entryId = hasOwn(entryMetadata, 'libraryConfigurationId') + ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId) + : normalizedId; + next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId }); + return next; }); - compressed.answerComparison = simplifiedComparison; } - return compressed; + return record; } - /** - * 存储数据 - */ - async set(key, value, options = {}) { - const { skipReady = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - throw new Error(`Storage.set(${key}) is disabled; use PracticeRecordAPI`); - } - return await this.writePersistentValue(key, value, options); - } catch (error) { - console.error('[Storage] set 操作错误:', error); - this.handleStorageError(key, value, error, options); - if (protectedPublicAccess) { - throw error; - } - return false; - } + function practiceRecordMatches(record, identities) { + const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean)); + if (!expected.size || !record || typeof record !== 'object') return false; + return ['id', 'recordId', 'sessionId'].some((field) => { + const value = record[field]; + return value !== undefined && value !== null && expected.has(String(value)); + }); } - /** - * 向数组追加新项 - * @param {string} key - 存储键名 - * @param {*} value - 要追加的项 - * @returns {Promise} 成功返回 true,失败返回 false - */ - async append(key, value, options = {}) { - const { skipReady = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - throw new Error(`Storage.append(${key}) is disabled; use PracticeRecordAPI`); - } - let currentList = await this.readPersistentValue(key, [], options); - if (!Array.isArray(currentList)) { - currentList = []; - } - currentList.push(value); - return await this.writePersistentValue(key, currentList, options); - } catch (error) { - console.error('[Storage] Append error:', error); - this.handleStorageError(key, value, error, options); - if (protectedPublicAccess) { - throw error; + function practiceLayerId(row) { + return String(row && (row.recordId || row.id || row.sessionId) || ''); + } + async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { + // The real kernel reads all three entity stores in one readonly + // IndexedDB transaction. Keep the fallback for deliberately minimal + // embedders and unit-test kernels that only expose the original methods. + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); + } + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + const summaries = ids === null + ? await kernel.listEntities('practiceSummaries', { withMeta }) + : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); + const targetIds = summaries.map(practiceLayerId).filter(Boolean); + const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) + .then((rows) => rows.filter(Boolean)); + return { + practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], + practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], + practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] + }; + } + async function practiceLayers(recordId, withMeta = false) { + const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + return { + summary: find('practiceSummaries'), + detail: find('practiceDetails'), + annotations: find('practiceAnnotations') + }; + } + async function suiteChildRecordIds(command, aggregateRecordId) { + const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); + const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); + if (sessionIds.size) { + const summaries = await kernel.listEntities('practiceSummaries'); + for (const summary of summaries) { + if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); } - return false; } + ids.delete(String(aggregateRecordId)); + return ids; + } + function entityRevision(row) { return row ? Number(row.revision) : 0; } + function practiceUpserts(recordId, layers, existing = {}) { + return [ + { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) }, + { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) }, + { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) } + ]; } + async function joinedPractice(recordId, projection, snapshot = null) { + const mode = String(projection || 'full').toLowerCase(); + const layers = snapshot || await practiceProjectionSnapshot([recordId], false, + mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : null)); + const summary = asArray(layers.practiceSummaries) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (!summary) return null; + if (mode === 'light' || mode === 'summary') return clone(summary); + const detail = asArray(layers.practiceDetails) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); + const annotations = asArray(layers.practiceAnnotations) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + return joinPracticeRecord(summary, detail, annotations, mode); + } + function practiceSummaryTime(summary) { + const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); + return Number.isFinite(time) ? time : 0; + } + function isReadingInsightSummary(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => practiceType(entry) === 'reading'); + } + return practiceType(asObject(summary)) === 'reading'; + } + function needsQuestionTypeInsightBackfill(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => + practiceType(entry) === 'reading' + && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); + } + return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + } + const practice = Object.freeze({ + async list(options = {}) { + await ready; + const projection = String(options.projection || 'full').toLowerCase(); + const snapshot = await practiceProjectionSnapshot(null, false, + projection === 'light' || projection === 'summary' + ? ['practiceSummaries'] + : null); + const summaries = asArray(snapshot.practiceSummaries); + if (projection === 'light' || projection === 'summary') return summaries; + return (await Promise.all(summaries + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) + .filter(Boolean); + }, + async listInsights(options = {}) { + await ready; + const requestedLimit = Number(options.limit); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 50) + : 10; + const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); + const summaries = asArray(snapshot.practiceSummaries) + .filter(isReadingInsightSummary) + .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) + .slice(0, limit); + return summaries.map((summary) => { + if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); + const detail = asArray(snapshot.practiceDetails) + .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; + return detail + ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) + : clone(summary); + }); + }, + async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, + async completeAttempt(command) { + await ready; + const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command; + const mutation = mutationOptions(command, 'practice-complete', source); + const recordInput = await practiceRecordWithLibraryProvenance(source, command); + if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId); + const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities( + practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation)); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async finalizeSuite(command) { + await ready; assertObject(command, 'finalizeSuite command is required'); + const mutation = mutationOptions(command, 'practice-suite', command); + const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); + if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); + const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command, async () => { + const existing = await practiceLayers(recordId, true); + const children = await suiteChildRecordIds(command, recordId); + const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); + return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); + }); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async updateAnnotations(command) { + await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || ''); + return retryMergeConflict(command, async () => { + const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`); + if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`); + const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data))); + const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default'); + if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) { + if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`); + annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) }); + } else { + if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`); + annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) }); + Object.assign(annotations, clone(asObject(command.patch))); + } + return kernel.mutateEntities([{ + type: 'upsert', + store: 'practiceAnnotations', + recordId, + data: annotations, + expectedRevision: entityRevision(current.annotations) + }], mutationOptions(command, 'practice-annotations', command)); + }); + }, + async delete(command) { + await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required'); + const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId })); + return Object.assign({}, receipt, { deletedCount: 1 }); + }, + async deleteMany(command) { + await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean))); + if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id]))); + if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); + }, + async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, + projectLight, + projectDetail + }); - async get(key, defaultValue = null, options = {}) { - const { skipReady = false, skipPracticeCoreRedirect = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - return await this.readProtectedDataKey(key, defaultValue, options); - } - return await this.readPersistentValue(key, defaultValue, options); - } catch (error) { - console.error('Storage get error:', error); - if (protectedPublicAccess) { - throw error; + const settings = Object.freeze({ + async getAll() { await ready; return kernel.read('settings.values'); }, + async patch(values, options = {}) { + await ready; assertObject(values, 'settings.patch requires an object'); + const mutation = optionsMutationOptions(options, 'settings-patch', values); + return retryMergeConflict(options, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + }); + }, + async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); } + }); + + const library = Object.freeze({ + async listConfigurations() { await ready; return kernel.read('library.configurations'); }, + async getActive() { await ready; return kernel.read('library.activeConfigurationId'); }, + async getIndex(configurationId) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + if (id === null) return []; + const indexes = await kernel.read('library.importedIndexes'); + return asArray(indexes[id]); + }, + async updateConfiguration(configuration, options = {}) { + await ready; assertObject(configuration, 'library.updateConfiguration requires an object'); + const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId'])); + const current = await kernel.read('library.configurations', { withMeta: true }); + const configs = asArray(current.data); + const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id); + const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id }); + if (index >= 0) configs[index] = next; else configs.push(next); + return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration)); + }, + async activate(configurationId, options = {}) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + const current = await kernel.read('library.activeConfigurationId', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id })); + }, + async import(command) { + await ready; assertObject(command, 'library.import requires a command'); + const id = importedLibraryId(command.id || command.configurationId || randomId('library')); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id); + configs.push(Object.assign({}, asObject(command.configuration), { id, key: id })); + const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) }); + return kernel.mutate([ + { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ], mutationOptions(command, 'library-import', command)); + }, + async remove(configurationId, options = {}) { + await ready; const id = importedLibraryId(configurationId); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true }); + const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id]; + const changes = [ + { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ]; + if (String(activeMeta.data || '') === id) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 }); } - return defaultValue; + return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id })); + }, + async resolveIndex() { + await ready; + const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]); + return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]); } - } + }); - /** - * 删除数据 - */ - async remove(key, options = {}) { - const { skipReady = false } = options; - const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options); - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (protectedPublicAccess) { - throw new Error(`Storage.remove(${key}) is disabled; use PracticeRecordAPI`); - } - return await this.removePersistentValue(key, options); - } catch (error) { - console.error('Storage remove error:', error); - if (protectedPublicAccess) { - throw error; - } - return false; + function recoveryKey(kind) { + const key = RECOVERY_KEYS[String(kind || '')]; + if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`); + return key; + } + // Recovery document TTL is an AppData domain rule, not a catalog policy field. + const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000; + function recoveryTimestamp(item) { + for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) { + const parsed = Date.parse(item && item[field]); + if (Number.isFinite(parsed)) return parsed; } + return null; } - - /** - * 清空所有数据 - */ - async clear(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - await this.ensureIndexedDBReady(); - if (!hasInternalAccessOptions(options)) { - const api = await this.getPracticeRecordAPI(options); - if (!api || typeof api.clear !== 'function' || typeof api.resetStats !== 'function') { - throw new Error('Storage.clear: PracticeRecordAPI clear/resetStats not ready'); - } - await api.clear({ updateStats: false }); - await api.resetStats(); + async function pruneRecoveryKey(logicalKey) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await kernel.read(logicalKey, { withMeta: true }); + const items = asArray(current.data); + const cutoff = Date.now() - RECOVERY_TTL_MS; + const retained = items.filter((item) => { + const timestamp = recoveryTimestamp(item); + return timestamp === null || timestamp > cutoff; + }); + if (retained.length === items.length) return items; + try { + await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], { + operationId: randomId('recovery-ttl') + }); + return retained; + } catch (error) { + if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error; } - return await this.clearPersistentStorage(createInternalAccessOptions(options)); - } catch (error) { - console.error('Storage clear error:', error); - return false; } + return kernel.read(logicalKey); + } + async function cleanupExpiredRecovery() { + for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey); } + const windowSession = Object.freeze({ + save(name, value) { + if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable'); + const logicalName = String(name || 'default'); + const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) }; + global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload)); + return true; + }, + get(name) { + if (!global.sessionStorage) return null; + const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + if (!raw) return null; + const payload = JSON.parse(raw); + return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null; + }, + discard(name) { + if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + return true; + } + }); - /** - * 检查存储配额是否充足 - */ - async checkStorageQuota(dataSize, options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - console.log(`[Storage] 检查存储配额,需要空间: ${dataSize} 字节`); - if (this.fallbackStorage) { - console.log('[Storage] 内存存储,无配额限制'); - return true; // 内存存储没有配额限制 - } + const recoveryMutationTails = new Map(); + function enqueueRecoveryMutation(logicalKey, task) { + const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve(); + const result = previous.then(task, task); + recoveryMutationTails.set(logicalKey, result.catch(() => undefined)); + return result; + } - const storageInfo = await this.getStorageInfo({ skipReady }); - if (!storageInfo) { - console.warn('[Storage] 无法获取存储信息,拒绝操作'); - return false; + async function readRecovery(kind, id) { + await ready; + const items = await pruneRecoveryKey(recoveryKey(kind)); + return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null; + } + async function saveRecovery(kind, value, options = {}) { + await ready; assertObject(value, `recovery ${kind} value must be an object`); + const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value); + const key = recoveryKey(kind); + const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId); + const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() }); + const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + if (index >= 0) current.items[index] = item; else current.items.push(item); + return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation); + })); + const committedItem = (await kernel.read(key)) + .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + return Object.assign({}, receipt, { item: clone(committedItem || item) }); + } + async function discardRecovery(kind, id, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id)); + return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation); + })); + } + async function clearRecovery(kind, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) { + throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision }); } + return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation); + })); + } + async function clearAllRecovery(options = {}) { + const results = {}; + for (const kind of Object.keys(RECOVERY_KEYS)) { + results[kind] = await clearRecovery(kind, options); + } + return results; + } + const recovery = Object.freeze({ + windowSession, + async clear(options = {}) { return clearAllRecovery(options); }, + async listActiveSessions() { return readRecovery('activeSession'); }, + async getActiveSession(id) { return readRecovery('activeSession', id); }, + async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); }, + async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async listDrafts() { return readRecovery('draft'); }, + async getDraft(id) { return readRecovery('draft', id); }, + async saveDraft(value, options) { return saveRecovery('draft', value, options); }, + async discardDraft(id, options) { return discardRecovery('draft', id, options); }, + async listInterrupted() { return readRecovery('interrupted'); }, + async getInterrupted(id) { return readRecovery('interrupted', id); }, + async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); }, + async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); }, + async listRejectedCompletions() { return readRecovery('rejectedCompletion'); }, + async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); }, + async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); }, + async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); } + }); - console.log(`[Storage] 当前存储类型: ${storageInfo.type}, 已用: ${storageInfo.used} 字节`); + function isImportableEntry(entry) { + return entry + && entry.classification !== 'system' + && entry.classification !== 'session' + && entry.import !== 'ignore'; + } - if (storageInfo.type === 'Hybrid' || storageInfo.type === 'IndexedDB') { - // 混合存储或IndexedDB没有固定配额限制,但我们仍然检查数据大小 - const maxSize = 105 * 1024 * 1024; // 105MB限制 (localStorage 5MB + IndexedDB 100MB) - const hasSpace = storageInfo.used + dataSize <= maxSize; - console.log(`[Storage] Hybrid/IndexedDB 检查: 已用 ${storageInfo.used}, 需要 ${dataSize}, 最大 ${maxSize}, 结果: ${hasSpace}`); - return hasSpace; - } + function isPlainImportObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } - const currentUsage = storageInfo.used; - const quota = 5 * 1024 * 1024; // 5MB - const availableSpace = quota - currentUsage; + function isV2SnapshotShape(parsed) { + return isPlainImportObject(parsed) + && parsed.format === 'ielts-atlas-data-v2' + && isPlainImportObject(parsed.envelopes) + && isPlainImportObject(parsed.entities); + } - // 预留20%的缓冲空间 - const bufferSpace = quota * 0.2; - const safeAvailableSpace = availableSpace - bufferSpace; + const POISONED_V2_WRAPPER_ALIASES = Object.freeze({ + 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']), + 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']), + 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state']) + }); + const LIBRARY_IMPORT_KEYS = Object.freeze([ + 'library.configurations', + 'library.importedIndexes', + 'library.activeConfigurationId' + ]); - console.log(`[Storage] localStorage 检查: 当前使用 ${(currentUsage / 1024).toFixed(2)}KB, 总配额 ${quota / 1024}KB, 可用 ${(availableSpace / 1024).toFixed(2)}KB, 安全可用 ${(safeAvailableSpace / 1024).toFixed(2)}KB, 需要 ${(dataSize / 1024).toFixed(2)}KB`); + function parseLegacyImportValue(value) { + if (typeof internals.parseLegacyValue !== 'function') { + throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); + } + return internals.parseLegacyValue(value); + } - const hasSpace = safeAvailableSpace >= dataSize; - if (!hasSpace) { - console.warn('[Storage] localStorage 空间不足'); - } - return hasSpace; - } catch (error) { - console.error('[Storage] 配额检查错误:', error); - return false; + function decodePoisonedDocument(logicalKey, wrapped) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; + if (!aliases || !isPlainImportObject(wrapped) + || !Object.prototype.hasOwnProperty.call(wrapped, 'key') + || !Object.prototype.hasOwnProperty.call(wrapped, 'value') + || !aliases.includes(String(wrapped.key))) { + return { matched: false, value: null }; + } + const decoded = parseLegacyImportValue(wrapped.value); + if (!isPlainImportObject(decoded)) return { matched: true, value: null }; + const overlay = {}; + for (const [key, value] of Object.entries(wrapped)) { + if (key === 'key' || key === 'value' || key === 'timestamp') continue; + overlay[key] = clone(value); } + return { + matched: true, + value: Object.assign({}, decoded, overlay) + }; } - /** - * 获取存储使用情况 - */ - async getStorageInfo(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - if (this.fallbackStorage) { - return { - type: 'volatile', - mode: this.mode, - volatile: true, - used: this.fallbackStorage.size, - available: Infinity - }; - } + function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { + if (!envelope || envelope.state !== 'present') return envelope; + const wrapped = envelope.data; + const decoded = decodePoisonedDocument(logicalKey, wrapped); + if (!decoded.matched || !decoded.value) return envelope; + const entry = catalog.get(logicalKey); + const next = internals.makeEnvelope(entry, decoded.value, { + revision: Number(envelope.revision) || 1, + operationId: String(envelope.operationId || randomId('import-repair')), + updatedAt: envelope.updatedAt + }); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + return next; + } - if (this.indexedDB && !this.indexedDBBlocked) { - const indexedDBUsed = await this.getIndexedDBUsage(); - return { - type: 'indexedDB', - mode: this.mode, - volatile: false, - used: indexedDBUsed, - available: Infinity, - breakdown: { - indexedDB: indexedDBUsed - } - }; + function validateLibraryImportBundle(envelopes) { + const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (!presentKeys.length) return { valid: true, presentKeys }; + if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { + return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; + } + const values = {}; + for (const key of LIBRARY_IMPORT_KEYS) { + const envelope = envelopes[key]; + values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; + } + const configurations = asArray(values['library.configurations']); + const indexes = asObject(values['library.importedIndexes']); + const configurationIds = new Set(); + for (const configuration of configurations) { + const source = asObject(configuration); + const id = idOf(source, ['id', 'key', 'configId']); + if (!id || !acceptedLibraryId(id) || source.builtIn === true + || !Array.isArray(indexes[id]) || !indexes[id].length) { + return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; + } + configurationIds.add(id); + } + for (const [id, index] of Object.entries(indexes)) { + if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { + return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; + } + } + const activeId = values['library.activeConfigurationId']; + if (activeId !== null && (!acceptedLibraryId(activeId) + || !configurationIds.has(String(activeId)) + || !Array.isArray(indexes[String(activeId)]) + || !indexes[String(activeId)].length)) { + return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; + } + return { valid: true, presentKeys }; + } + + function canonicalizeV2Import(parsed) { + const warnings = []; + const repairedKeys = []; + const ignoredKeys = []; + const envelopes = {}; + for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + if (logicalKey === 'library.activeConfigurationId' + && rawEnvelope && rawEnvelope.state === 'present' + && String(rawEnvelope.data) === '[object Object]') { + ignoredKeys.push(logicalKey); + warnings.push('Skipped poisoned active library id'); + continue; } - - if (this.fallbackStorage) { - return { - type: 'memory', - used: this.fallbackStorage.size, - available: Infinity - }; + const repairCount = repairedKeys.length; + const repaired = repairPoisonedImportEnvelope( + logicalKey, + clone(rawEnvelope), + repairedKeys, + warnings + ); + const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; + const isLegacyRowWrapper = isPlainImportObject(rawData) + && Object.prototype.hasOwnProperty.call(rawData, 'key') + && Object.prototype.hasOwnProperty.call(rawData, 'value') + && String(rawData.key || '').startsWith('exam_system_'); + if (isLegacyRowWrapper && repairedKeys.length === repairCount) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; } - - if (this.indexedDB) { - try { - // 获取所有存储的使用情况 - const localStorageUsed = this.getLocalStorageUsage(); - const indexedDBUsed = await this.getIndexedDBUsage(); - const totalUsed = localStorageUsed + indexedDBUsed; - - return { - type: 'Hybrid', - used: totalUsed, - available: Infinity, // 混合存储没有固定配额 - breakdown: { - localStorage: localStorageUsed, - indexedDB: indexedDBUsed - } - }; - } catch (error) { - console.warn('[Storage] 获取混合存储使用情况失败:', error); - // 降级到localStorage - } + envelopes[logicalKey] = repaired; + } + const library = parsed.scope === 'full' + ? validateLibraryImportBundle(envelopes) + : { valid: true, presentKeys: [] }; + if (!library.valid) { + for (const logicalKey of library.presentKeys) { + delete envelopes[logicalKey]; + ignoredKeys.push(logicalKey); } - - let used = 0; - const keys = Object.keys(localStorage); - keys.forEach(key => { - if (key.startsWith(this.prefix)) { - used += localStorage.getItem(key).length; - } - }); - - return { - type: 'localStorage', - used: used, - available: 5 * 1024 * 1024 - used // 假设5MB限制 - }; - } catch (error) { - console.error('Storage info error:', error); - return null; + warnings.push(`Skipped unsafe library data: ${library.reason}`); + } + const exportableKeys = catalog.list() + .filter((entry) => entry.export === true && isImportableEntry(entry)) + .map((entry) => entry.logicalKey); + const missingKeys = parsed.scope === 'full' + ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) + : []; + if (parsed.scope === 'full' && missingKeys.length) { + warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); } + return { + envelopes, + warnings, + repairedKeys, + ignoredKeys, + missingKeys, + declaredScope: parsed.scope, + effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, + trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length + ? 'trusted-full' + : 'degraded-partial' + }; } - /** - * 获取localStorage使用情况 - */ - getLocalStorageUsage() { - try { - let used = 0; - const keys = Object.keys(localStorage); - keys.forEach(key => { - if (key.startsWith(this.prefix)) { - used += localStorage.getItem(key).length; - } - }); - return used; - } catch (error) { - console.error('Get localStorage usage error:', error); - return 0; - } + function resolveImportReplaceFlags(options = {}) { + const source = asObject(options); + const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase(); + const replaceAll = source.replace === true; + return { + replaceDocuments: replaceAll, + // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge. + replacePractice: replaceAll || practiceMode === 'replace' + }; } - /** - * 获取IndexedDB使用情况 - */ - getIndexedDBUsage() { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; + function pickFirstRecordArray(candidates) { + for (const candidate of asArray(candidates)) { + if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) { + return { source: candidate.source, records: candidate.records }; } - - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly'); - const store = transaction.objectStore('keyValueStore'); - const request = store.getAll(); - - request.onsuccess = () => { - const items = request.result; - let totalSize = 0; - - items.forEach(item => { - if (item.key.startsWith(this.prefix) && item.value) { - totalSize += item.value.length; - } - }); - - resolve(totalSize); - }; - - request.onerror = () => reject(request.error); - }); + } + return null; } /** - * 清理旧数据 + * Historical v1 export shapes (opensource / pre-AppData-v2): + * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats } + * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? } + * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*) + * - bare array of records, or { records: [...] } + * Recognition only — no dual backend and no local store migration. */ - async cleanupOldData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - console.log('[Storage] 开始清理旧数据...'); - - const practiceRecords = await this.listPracticeRecordsCanonical({ skipReady }); - if (practiceRecords.length > 0) { - console.log(`[Storage] 练习记录数据保留${practiceRecords.length}条记录,跳过压缩以保护答案数据完整性`); + function extractLegacyPracticeRecords(payload) { + const sources = []; + const add = (source, records) => { + if (Array.isArray(records) && records.some(isPlainImportObject)) { + sources.push({ source, records }); } + }; - // 清理错误日志 - const errorLogs = await this.get('injection_errors', [], { skipReady }); - if (errorLogs.length > 20) { - const logsToKeep = errorLogs.slice(-20); // 保留最近20条 - await this.set('injection_errors', logsToKeep, { skipReady }); - console.log(`[Storage] 已清理错误日志,从${errorLogs.length}条减少到${logsToKeep.length}条`); + if (Array.isArray(payload)) { + add('(root array)', payload); + } else if (isPlainImportObject(payload)) { + const preferred = pickFirstRecordArray([ + { source: 'practice_records', records: payload.practice_records }, + { source: 'practiceRecords', records: payload.practiceRecords }, + { source: 'records', records: payload.records } + ]); + if (preferred) add(preferred.source, preferred.records); + + const data = isPlainImportObject(payload.data) ? payload.data : null; + if (data) { + const nested = pickFirstRecordArray([ + { source: 'data.practice_records', records: data.practice_records }, + { source: 'data.practiceRecords', records: data.practiceRecords } + ]); + if (nested) add(nested.source, nested.records); + else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data); + else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data); + if (isPlainImportObject(data.exam_system_practice_records)) { + add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); + } } - - const collectionErrors = await this.get('collection_errors', [], { skipReady }); - if (collectionErrors.length > 20) { - const logsToKeep = collectionErrors.slice(-20); - await this.set('collection_errors', logsToKeep, { skipReady }); - console.log(`[Storage] 已清理数据收集错误日志,从${collectionErrors.length}条减少到${logsToKeep.length}条`); + if (isPlainImportObject(payload.exam_system_practice_records)) { + add('exam_system_practice_records.data', payload.exam_system_practice_records.data); } + } - // 清理活动会话(保留最近的) - const activeSessions = await this.get('active_sessions', [], { skipReady }); - const now = Date.now(); - const recentSessions = activeSessions.filter(session => { - const sessionTime = new Date(session.startTime).getTime(); - const hoursDiff = (now - sessionTime) / (1000 * 60 * 60); - return hoursDiff < 1; // 只保留1小时内的会话 - }); - - if (recentSessions.length !== activeSessions.length) { - await this.set('active_sessions', recentSessions, { skipReady }); - console.log(`[Storage] 已清理过期会话,从${activeSessions.length}个减少到${recentSessions.length}个`); + const seen = new Set(); + const records = []; + for (const entry of sources) { + for (const item of asArray(entry.records)) { + if (!isPlainImportObject(item)) continue; + const identity = idOf(item, ['id', 'recordId', 'sessionId']); + if (identity) { + if (seen.has(identity)) continue; + seen.add(identity); + } + records.push(item); } - - } catch (error) { - console.error('[Storage] 清理旧数据失败:', error); } + return { + records, + sources: sources.map((entry) => entry.source) + }; } - /** - * 迁移遗留数据到新命名空间 - * 只运行一次 - */ - async migrateLegacyData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - console.log('[Storage] 开始迁移遗留数据'); - try { - const legacyKeys = Object.keys(localStorage).filter(k => - k === 'practice_records' || - k === 'user_progress' || - k === 'scores' || - k.startsWith('old_prefix_') + function entityRowFromLayer(recordId, data, operationId) { + const payload = jsonValue(data, 'import practice entity'); + return { + recordId: String(recordId), + revision: 1, + operationId: String(operationId || `import-${recordId}`), + updatedAt: nowIso(), + data: payload, + checksum: checksum(payload) + }; + } + + function convertLegacyPracticeImport(payload) { + const extracted = extractLegacyPracticeRecords(payload); + if (!extracted.records.length) { + throw new AppDataError( + 'VALIDATION', + 'Import file is neither a v2 snapshot nor a recognizable v1 practice export' ); + } - if (legacyKeys.length === 0) { - console.log('[Storage] 无遗留数据需要迁移'); - await this.set('migration_completed', true, { skipReady }); - } else { - let migratedCount = 0; - let deferredPracticeMigration = false; - for (const oldKey of legacyKeys) { - try { - const legacyDataStr = localStorage.getItem(oldKey); - if (!legacyDataStr) continue; - - let legacyData; - try { - legacyData = JSON.parse(legacyDataStr); - } catch (parseError) { - console.warn(`[Storage] 解析遗留数据失败: ${oldKey}`, parseError); - continue; - } + const entities = { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + }; + const warnings = []; + let skipped = 0; - if (!Array.isArray(legacyData)) { - console.warn(`[Storage] 遗留数据非数组,跳过: ${oldKey}`); - continue; - } + for (const raw of extracted.records) { + try { + const layers = splitPracticeRecord(raw); + const recordId = layers.summary.id; + const operationId = `import-v1-${recordId}`; + entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId)); + entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId)); + entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId)); + } catch (error) { + skipped += 1; + warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`); + } + } - if (legacyData.length === 0) { - console.log('[Storage] 旧数据为空,跳过迁移'); - continue; - } + if (!entities.practiceSummaries.length) { + throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized'); + } - // 对应新键(去除 old_prefix_ 如果存在) - let newKey = oldKey.replace(/^old_prefix_/, ''); - const isPracticeRecordsKey = newKey === 'practice_records'; - if (isPracticeRecordsKey) { - await this.mergePracticeRecordsCanonical(legacyData, { skipReady }); - } else { - const current = await this.get(newKey, [], { skipReady }); - const merged = this.mergeRecords(current, legacyData); - await this.set(newKey, merged, { skipReady }); - } + const accepted = entities.practiceSummaries.length; + return { + format: 'v1', + scope: 'partial', + envelopes: {}, + entities, + checksum: null, + warnings, + practiceSummary: { + accepted, + importedCount: accepted, + skippedCount: skipped, + sources: extracted.sources.slice() + } + }; + } - // 删除旧键 - localStorage.removeItem(oldKey); - migratedCount++; - console.log(`[Storage] 成功迁移并合并数据: ${oldKey} -> ${newKey} (${legacyData.length} 项)`); - } catch (migrateError) { - const newKey = oldKey.replace(/^old_prefix_/, ''); - if (newKey === 'practice_records') { - deferredPracticeMigration = true; - } - console.error(`[Storage] 迁移失败: ${oldKey}`, migrateError); - } - } + function parseImportPayload(payload) { + let parsed; + try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); } + catch (error) { + if (error instanceof AppDataError) throw error; + throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message }); + } - console.log(`[Storage] 数据迁移完成: ${migratedCount} 个键成功迁移`); - if (deferredPracticeMigration) { - console.warn('[Storage] 练习记录迁移已延后,等待 PracticeRecordAPI 就绪后重试'); - } else { - await this.set('migration_completed', true, { skipReady }); + // Bare record arrays are a historical import convenience (UI file pickers). + if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed); + if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object'); + + if (isV2SnapshotShape(parsed)) { + if (Number(parsed.schemaVersion) !== Number(catalog.version)) { + throw new AppDataError('VALIDATION', 'Import schema version mismatch'); + } + if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) { + throw new AppDataError('VALIDATION', 'Import checksum mismatch'); + } + if (parsed.scope !== 'full' && parsed.scope !== 'partial') { + throw new AppDataError('VALIDATION', 'Import scope must be full or partial'); + } + const scope = parsed.scope; + for (const [store, rows] of Object.entries(parsed.entities)) { + if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) { + throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`); + } + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) { + throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + } } } - - if (!await this.get('my_melody_migration_completed', null, { skipReady })) { - console.log('[Storage] 检查 MyMelody 遗留键迁移...'); - const canonicalPracticeKey = this.getKey('practice_records'); - console.warn('[Storage] 跳过 MyMelody 遗留键迁移:旧键与 canonical practice_records 键相同,继续迁移会误删当前记录', canonicalPracticeKey); - await this.set('my_melody_migration_completed', true, { skipReady }); + if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) { + throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer'); } - - } catch (error) { - console.error('[Storage] 迁移遗留数据失败:', error); - // 即使失败也设置标志,避免无限重试 - await this.set('migration_completed', true, { skipReady }); + const canonical = canonicalizeV2Import(parsed); + return { + format: 'v2', + scope: canonical.effectiveScope, + declaredScope: canonical.declaredScope, + envelopes: canonical.envelopes, + entities: parsed.entities, + checksum: parsed.checksum, + warnings: canonical.warnings, + practiceSummary: null, + repairedKeys: canonical.repairedKeys, + ignoredKeys: canonical.ignoredKeys, + missingKeys: canonical.missingKeys, + trust: canonical.trust + }; } - } - /** - * 从备份文件恢复数据 - */ - async restoreFromBackup(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - console.log('[Storage] 开始从备份恢复数据'); - - const backupPath = 'assets/data/backup-practice-records.json'; - const isFileProtocol = typeof window !== 'undefined' - && window.location - && window.location.protocol === 'file:'; - - // Chromium 下 fetch(file://...) 会直接抛错;备份属于可选项,跳过即可。 - if (isFileProtocol) { - console.info('[Storage] file:// 环境跳过内置备份恢复'); - return false; + // Explicit but malformed v2 claims must not fall through to legacy parsers. + if (parsed.format === 'ielts-atlas-data-v2') { + throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported'); } - try { - const response = await fetch(backupPath); - if (!response.ok) { - return false; - } - const backupData = await response.json(); - if (!backupData || !Array.isArray(backupData.practice_records)) { - console.warn('[Storage] 备份数据格式无效'); - return false; - } - // 运行期恢复必须走统一记录 API;raw practice_records 只允许启动迁移兼容使用。 - await this.replacePracticeRecordsCanonical(backupData.practice_records, { skipReady }); - console.log('[Storage] 从备份恢复 practice_records 成功'); - return true; - } catch (error) { - console.warn('[Storage] 备份恢复失败,已跳过:', error); - return false; - } + return convertLegacyPracticeImport(parsed); } - /** - * 处理存储错误 - */ - handleStorageError(key, value, error, options = {}) { - console.error('[Storage] 存储错误:', error); + function collectionIdentityFields(logicalKey) { + if (logicalKey === 'library.configurations') return ['id', 'key', 'configId']; + if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId']; + if (logicalKey === 'backups.entries') return ['id']; + if (logicalKey === 'vocab.words') return ['id', 'word', 'key']; + if (logicalKey === 'goals.items') return ['id', 'goalId']; + return ['id', 'sessionId', 'recordId']; + } - // 如果是配额错误,尝试切换到备用存储 - if (error.name === 'QuotaExceededError') { - this.handleStorageQuotaExceeded(key, value, options); - } else { - // 其他错误 - if (window.showMessage) { - window.showMessage('数据保存失败,请检查浏览器设置', 'error'); - } + function collectionIdentity(logicalKey, value) { + const identity = idOf(value, collectionIdentityFields(logicalKey)); + return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity; + } - // 触发存储错误事件 - document.dispatchEvent(new CustomEvent('storageError', { - detail: { key, value, error } - })); + function mergeCollection(existing, incoming, logicalKey) { + const result = asArray(existing).map((item) => clone(item)); + const positions = new Map(); + result.forEach((item, index) => { + const identity = collectionIdentity(logicalKey, item); + if (identity) positions.set(identity, index); + }); + for (const rawItem of asArray(incoming)) { + const item = jsonValue(rawItem, `${logicalKey} item`); + const identity = collectionIdentity(logicalKey, item); + if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`); + if (positions.has(identity)) result[positions.get(identity)] = item; + else { + positions.set(identity, result.length); + result.push(item); + } } + return result; } - /** - * 导出数据 - */ - async exportData(options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - const data = {}; - - // 1. 导出内存存储数据 - if (this.fallbackStorage) { - this.fallbackStorage.forEach((value, key) => { - if (key.startsWith(this.prefix)) { - if (this.isProtectedStorageKey(key)) { - return; - } - const cleanKey = key.replace(this.prefix, ''); - data[cleanKey] = JSON.parse(value); - } - }); - console.log(`[Storage] 已导出内存存储数据 ${this.fallbackStorage.size} 条`); + function mergeImportValue(entry, existing, incoming) { + const policy = entry.import; + if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey); + if (policy === 'patch') { + if (Array.isArray(existing) || Array.isArray(incoming)) { + // Array-shaped keys should use merge-by-id; treat accidental patch as replace. + return clone(incoming); } + return Object.assign({}, asObject(existing), asObject(incoming)); + } + if (policy === 'replace') return clone(incoming); + throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`); + } - // 2. 导出IndexedDB数据 - if (this.indexedDB) { - try { - const items = await this.getAllFromIndexedDB(); - const indexedDBData = {}; - items.forEach(item => { - if (item.key.startsWith(this.prefix) && !this.isProtectedStorageKey(item.key)) { - const cleanKey = item.key.replace(this.prefix, ''); - indexedDBData[cleanKey] = JSON.parse(item.value); - } - }); - // 合并IndexedDB数据 - Object.assign(data, indexedDBData); - console.log(`[Storage] 已导出IndexedDB数据 ${Object.keys(indexedDBData).length} 条`); - } catch (error) { - console.warn('[Storage] IndexedDB导出失败:', error); - } + async function currentEntitySnapshot() { + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(null, { withMeta: true }); + } + const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); + const result = {}; + for (const store of PRACTICE_ENTITY_STORES) { + if (store === 'practiceSummaries') result[store] = summaries; + else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean); + } + return result; + } + function practiceEntityIds(rows) { + return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean)); + } + function assertPracticeEntitySetsMatch(entities, message) { + const expected = practiceEntityIds(entities.practiceSummaries); + for (const store of PRACTICE_ENTITY_STORES.slice(1)) { + const actual = practiceEntityIds(entities[store]); + if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) { + throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', { + counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size])) + }); } - - // 3. 导出localStorage数据 - const localStorageKeys = Object.keys(localStorage); - const appKeys = localStorageKeys.filter(key => key.startsWith(this.prefix)); - appKeys.forEach(key => { - const cleanKey = key.replace(this.prefix, ''); - if (this.isProtectedDataKey(cleanKey)) { - return; - } - try { - const value = localStorage.getItem(key); - if (value) { - data[cleanKey] = JSON.parse(value); - } - } catch (error) { - console.warn(`[Storage] 解析localStorage数据失败: ${cleanKey}`, error); - } + } + } + async function createImportPlan(parsed, options = {}) { + const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options); + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} }; + const revisionToken = { documents: {}, entities: {} }; + const keys = []; const clearedKeys = []; + const warnings = asArray(parsed.warnings).map(String); + for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue; + if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`); + if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) { + warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); + continue; + } + const currentRead = await kernel.read(logicalKey, { withMeta: true }); + const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; + const currentEnvelope = currentRead && currentRead.envelope; + revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + let next = envelope; + if (!replaceDocuments && envelope.state === 'present') { + next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + } + snapshot.envelopes[logicalKey] = next; + keys.push(logicalKey); + if (next.state === 'cleared') clearedKeys.push(logicalKey); + } + + // Any successful practice import installs all three stores together. Merge + // may update a subset only when the final recordId sets remain identical. + const sourceStores = Object.keys(asObject(parsed.entities)); + let practiceExistingCount = null; + let practiceIncomingCount = null; + if (sourceStores.length) { + if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) { + throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations'); + } + const current = await currentEntitySnapshot(); + revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries( + asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0]) + )])); + practiceExistingCount = asArray(current.practiceSummaries).length; + practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length; + const existing = replacePractice + ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []])) + : current; + for (const store of PRACTICE_ENTITY_STORES) { + const rows = asArray(existing[store]).map(clone); + const positions = new Map(rows.map((row, index) => [String(row.recordId), index])); + for (const row of asArray(parsed.entities[store])) { + if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + const index = positions.get(String(row.recordId)); + if (index === undefined) { + positions.set(String(row.recordId), rows.length); + rows.push(clone(row)); + } else rows[index] = clone(row); + } + snapshot.entities[store] = rows; + } + assertPracticeEntitySetsMatch(snapshot.entities); + } + + snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities }); + const practiceSummary = parsed.practiceSummary + ? clone(parsed.practiceSummary) + : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries') + ? { + accepted: Number(practiceIncomingCount) || 0, + importedCount: Number(practiceIncomingCount) || 0, + skippedCount: 0, + existingCount: Number(practiceExistingCount) || 0, + incomingCount: Number(practiceIncomingCount) || 0, + finalCount: asArray(snapshot.entities.practiceSummaries).length, + removedCount: Math.max(0, (Number(practiceExistingCount) || 0) + - asArray(snapshot.entities.practiceSummaries).length) + } + : null); + if (practiceSummary && practiceSummary.existingCount === undefined) { + practiceSummary.existingCount = Number(practiceExistingCount) || 0; + practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0; + practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length; + practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount); + } + const destructive = clearedKeys.length > 0 + || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0); + return { + snapshot, + keys, + clearedKeys, + warnings, + practiceSummary, + destructive, + resetJournal: replaceDocuments && replacePractice, + revisionToken, + diagnostics: { + format: parsed.format, + replaceDocuments, + replacePractice, + declaredScope: parsed.declaredScope || parsed.scope, + effectiveScope: parsed.scope, + trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'), + missingKeys: clone(parsed.missingKeys || []), + repairedKeys: clone(parsed.repairedKeys || []), + ignoredKeys: clone(parsed.ignoredKeys || []) + } + }; + } + async function createRestoreSnapshot(backup) { + const parsed = parseImportPayload(asObject(backup && backup.data)); + if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups'); + if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch'); + return (await createImportPlan(parsed, { replace: true })).snapshot; + } + + const backups = Object.freeze({ + onDataCommitted(listener) { return kernel.onCommitted(listener); }, + async getSettings() { await ready; return kernel.read('backups.settings'); }, + async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); }, + async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); }, + async getImportHistory() { await ready; return kernel.read('backups.importHistory'); }, + async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); }, + async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); }, + async create(options = {}) { + await ready; const current = await readCollectionMeta('backups.entries'); + const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' }); + const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup')); + const existing = current.items.find((item) => String(item.id) === String(backupId)); + if (existing) { + if (String(existing.operationId || '') === String(mutation.operationId) + && String(existing.type || 'manual') === String(options.type || 'manual')) { + return clone(existing); + } + throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, { + backupId: String(backupId) + }); + } + const snapshot = await kernel.exportSnapshot(); + const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum }; + current.items.unshift(backup); + current.items = retainBackupEntries(current.items, 20, options.preserveIds); + await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation); + const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId)); + return clone(committed || backup); + }, + async list() { await ready; return kernel.read('backups.entries'); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); }, + async export(options = {}) { + await ready; + if (options.backupId !== undefined && options.backupId !== null) { + const backupId = String(options.backupId); + const stored = asArray(await kernel.read('backups.entries')) + .find((item) => String(item && item.id) === backupId); + if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`); + const portable = jsonValue(stored, 'stored backup export'); + if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) { + throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`); + } + return portable; + } + const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null; + const logicalKeys = domains + ? catalog.list() + .filter((entry) => domains.has(entry.owner) && entry.export === true) + .map((entry) => entry.logicalKey) + : null; + const entityStores = !domains || domains.has('practice') + ? undefined + : []; + return kernel.exportSnapshot(Object.assign( + logicalKeys ? { logicalKeys } : {}, + entityStores ? { entityStores } : {} + )); + }, + async previewImport(payload, options = {}) { + await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan'); + const cutoff = Date.now() - (30 * 60 * 1000); + for (const [id, existing] of importPlans) { + if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id); + } + const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) }; + importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive }; + }, + async commitImport(planId, options = {}) { + await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`); + if (plan.destructive && options.confirmDestructive !== true) { + throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation'); + } + const mutation = optionsMutationOptions(options, 'import-commit', { + planId: plan.id, + signature: plan.signature + }, { warnings: plan.warnings }); + const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, { + resetJournal: plan.resetJournal === true, + expectedRevisionToken: plan.revisionToken + })); + importPlans.delete(String(planId)); + return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) }); + }, + async restore(id, options = {}) { + await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id)); + if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`); + const snapshot = await createRestoreSnapshot(backup); + const restoreMutation = optionsMutationOptions(options, 'backup-restore', { + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }, { resetJournal: true }); + const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`; + const preRestoreBackupId = `pre_restore_${checksum({ + operationId: restoreMutation.operationId, + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }).replace(/[^a-z0-9]/gi, '')}`; + const preRestoreBackup = await backups.create({ + id: preRestoreBackupId, + operationId: preRestoreOperationId, + type: 'pre-restore', + preserveIds: [String(id)] }); - console.log(`[Storage] 已导出localStorage数据 ${appKeys.length} 条`); - - data.practice_records = await this.readProtectedDataKey('practice_records', [], { skipReady }); - data.user_stats = await this.readProtectedDataKey('user_stats', null, { skipReady }); - - console.log(`[Storage] 数据导出完成,总计 ${Object.keys(data).length} 条记录`); + const receipt = await kernel.installSnapshot(snapshot, restoreMutation); + return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id }); + } + }); - return { - version: this.version, - exportDate: new Date().toISOString(), - data: data, - storageInfo: { - totalRecords: Object.keys(data).length, - sources: { - memory: this.fallbackStorage ? this.fallbackStorage.size : 0, - indexedDB: this.indexedDB ? Object.keys(data).length - (this.fallbackStorage ? this.fallbackStorage.size : 0) - appKeys.length : 0, - localStorage: appKeys.length + let vocabMutationTail = Promise.resolve(); + function enqueueVocabMutation(task) { + const result = vocabMutationTail.then(task, task); + vocabMutationTail = result.catch(() => undefined); + return result; + } + function retryVocabMutation(options, task) { + return enqueueVocabMutation(() => retryMergeConflict(options, task)); + } + + const vocab = Object.freeze({ + async listWords() { await ready; return kernel.read('vocab.words'); }, + async saveWords(words, options = {}) { + await ready; assertArray(words, 'vocab.saveWords requires an array'); + const mutation = optionsMutationOptions(options, 'vocab-words', words); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.words', { withMeta: true }); + return mutateAndProject([{ + logicalKey: 'vocab.words', + data: words, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async getConfig() { await ready; return kernel.read('vocab.userConfig'); }, + async setConfig(config, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'vocab-config', config); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: asObject(config), + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async patchConfig(patch, options = {}) { + await ready; assertObject(patch, 'vocab.patchConfig requires an object'); + const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), clone(patch)); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); }, + async listCollections() { await ready; return kernel.read('vocab.lists'); }, + async saveCollection(id, value, options = {}) { + await ready; + const collectionId = String(id); + const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async saveCollections(values, options = {}) { + await ready; + assertObject(values, 'vocab.saveCollections requires an object'); + const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)])); + const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), upserts); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async upsertCollectionWord(collectionId, word, options = {}) { + await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word'); + const id = String(collectionId || ''); + if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required'); + const identity = String(word.word || word.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + const existing = collections[id]; + const list = existing && typeof existing === 'object' && !Array.isArray(existing) + ? Object.assign({}, clone(existing), { words: asArray(existing.words) }) + : { id, words: asArray(existing) }; + const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity); + const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() }); + if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt; + if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); + list.updatedAt = nowIso(); + collections[id] = list; + const receipt = await mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(nextWord) }); + }); + }, + async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; }, + async replaceListWords(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceListWords requires a command'); + const id = String(command.listId || 'default'); const words = asArray(command.words); + if (id === 'default') return this.saveWords(words, options); + const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async mergeListWords(command, options = {}) { + await ready; + assertObject(command, 'vocab.mergeListWords requires a command'); + const listId = String(command.listId || 'default'); + const incoming = asArray(command.words); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions(options, 'vocab-words-merge', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data)); + const storedList = listId === 'default' + ? asArray(current.data) + : (function readStoredCollection() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? asArray(collection.words) + : asArray(collection); + }()); + const merged = storedList.map((word) => clone(word)); + const positions = new Map(); + merged.forEach((word, index) => { + const identity = String(word && (word.word || word.id) || '').trim().toLowerCase(); + if (identity) positions.set(identity, index); + }); + let addedCount = 0; + let updatedCount = 0; + for (const rawWord of incoming) { + assertObject(rawWord, 'vocab.mergeListWords entries must be objects'); + const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + if (!positions.has(identity)) { + positions.set(identity, merged.length); + merged.push(clone(rawWord)); + addedCount += 1; + continue; } + const index = positions.get(identity); + const existing = asObject(merged[index]); + const patch = {}; + if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim(); + if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim(); + if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq; + merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() }); + updatedCount += 1; + } + const data = listId === 'default' + ? merged + : Object.assign({}, collections, { + [listId]: Object.assign( + {}, + (function collectionBaseForWrite() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? clone(collection) + : {}; + }()), + { id: listId, words: merged, updatedAt: nowIso() } + ) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount }); + }); + }, + async patchWord(command, options = {}) { + await ready; assertObject(command, 'vocab.patchWord requires a command'); + const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || ''); + if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required'); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions( + Object.assign({}, options, { operationId: command.operationId || options.operationId }), + 'vocab-word-patch', + command + ); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : asObject(current.data); + const list = listId === 'default' + ? asArray(current.data) + : asArray(asObject(collections[listId]).words); + const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId); + if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`); + const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() }); + const next = list.slice(); next[index] = updated; + const data = listId === 'default' + ? next + : Object.assign({}, collections, { + [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(updated) }); + }); + }, + async replaceProgress(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceProgress requires a command'); + const listId = String(command.listId || 'default'); const words = asArray(command.words); + const mutation = optionsMutationOptions(options, 'vocab-progress', command); + return retryVocabMutation(options, async () => { + const configMeta = await kernel.read('vocab.userConfig', { withMeta: true }); + const changes = [{ + logicalKey: 'vocab.userConfig', + data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }), + expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0 + }]; + if (listId === 'default') { + const wordsMeta = await kernel.read('vocab.words', { withMeta: true }); + changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 }); + } else { + const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data)); + lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); + changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - }; - } catch (error) { - console.error('Export data error:', error); - return null; + return mutateAndProject(changes, mutation); + }); } - } + }); - /** - * 从IndexedDB获取所有数据 - */ - getAllFromIndexedDB() { - return new Promise((resolve, reject) => { - if (!this.indexedDB) { - reject(new Error('IndexedDB not available')); - return; - } + async function readPreferences() { await ready; return kernel.read('preferences.values'); } + let preferenceMutationTail = Promise.resolve(); + function enqueuePreferenceMutation(task) { + const result = preferenceMutationTail.then(task, task); + preferenceMutationTail = result.catch(() => undefined); + return result; + } + async function writePreference(field, value, options = {}) { + const mutation = optionsMutationOptions(options, 'preference-set', { field, value }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [field]: clone(value) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + async function patchPreference(field, patch, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const values = asObject(current.data); + const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + const preferences = Object.freeze({ + async getAll() { return readPreferences(); }, + async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); }, + async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); }, + async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); }, + async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); }, + async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); } + ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); }, + async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); }, + async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); }, + async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); }, + async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); }, + async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); }, + async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); }, + async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); } + }); - const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly'); - const store = transaction.objectStore('keyValueStore'); - const request = store.getAll(); + const goals = Object.freeze({ + async list() { await ready; return kernel.read('goals.items'); }, + async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); } + }); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); + function deliveryTimestamp(value) { + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN; + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function mergeDeliveryAcknowledgements(current, incoming) { + const merged = Object.assign({}, asObject(current)); + for (const [id, value] of Object.entries(asObject(incoming))) { + const key = String(id).trim(); + if (!key) continue; + const previous = deliveryTimestamp(merged[key]); + const next = deliveryTimestamp(value); + if (!hasOwn(merged, key) || (next && (!previous || next < previous))) { + merged[key] = next; + } else if (previous) { + merged[key] = previous; + } else { + merged[key] = null; + } + } + return merged; } - /** - * 导入数据 - */ - async importData(importedData, options = {}) { - const { skipReady = false } = options; - await this.waitForInitialization(skipReady); - try { - if (!importedData || !importedData.data) { - throw new Error('Invalid import data format'); - } - - const importEntries = Object.entries(importedData.data); - const api = await this.getPracticeRecordAPI({ skipReady }); - const hasPracticeRecords = importEntries.some(([key]) => key === 'practice_records'); - const hasUserStats = importEntries.some(([key]) => key === 'user_stats'); - if (hasPracticeRecords && (!api || typeof api.replace !== 'function')) { - throw new Error('Storage.importData: unified practice record store not ready'); - } - if (hasUserStats && (!api || typeof api.writeStats !== 'function')) { - throw new Error('Storage.importData: unified user stats store not ready'); - } - - // 备份当前数据 - const backup = await this.exportData({ skipReady }); - const importEntry = ([key, value]) => { - const nextValue = value && Object.prototype.hasOwnProperty.call(value, 'data') - ? value.data - : value; - if (key === 'practice_records') { - // updateStats: false — 导入时 user_stats 会通过 writeUserStatsCanonical 独立写入, - // 若此处 recalculateStats 会和并发写入竞争,导致备份中的统计值被覆盖。 - return this.replacePracticeRecordsCanonical(nextValue, { skipReady, updateStats: false }); - } - if (key === 'user_stats') { - return this.writeUserStatsCanonical(nextValue, { skipReady }); + const achievements = Object.freeze({ + async getAll() { + await ready; + const progress = await retryMergeConflict({}, async () => { + const [summaries, manual, current] = await Promise.all([ + kernel.listEntities('practiceSummaries'), + kernel.read('achievements.manual'), + kernel.read('achievements.progress', { withMeta: true }) + ]); + const projected = asObject(computeAchievementProgress(summaries, manual, current.data)); + if (checksum(projected) !== checksum(asObject(current.data))) { + await kernel.mutate([{ + logicalKey: 'achievements.progress', + data: projected, + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], { + operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}` + }); } - return this.set(key, nextValue, { skipReady }); - }; - - try { - // 清空现有数据 - await this.clear({ skipReady }); - - // 导入新数据 - const importPromises = importEntries.map(importEntry); - - await Promise.all(importPromises); - - return { success: true, message: 'Data imported successfully' }; - } catch (importError) { - // 恢复备份 - console.error('Import failed, restoring backup:', importError); - await this.clear({ skipReady }); + return projected; + }, 5); + if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh; + Object.defineProperty(progress, 'fresh', { value: true, enumerable: false }); + return progress; + }, + async retryPending() { return achievements.getAll(); }, + async acknowledgeDelivery(unlocked, options = {}) { + await ready; + assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object'); + const requested = clone(unlocked); + const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested); + return retryMergeConflict({}, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + const settingsValue = asObject(current.data); + const delivery = asObject(settingsValue.achievementDelivery); + const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested); + return kernel.mutate([{ + logicalKey: 'settings.values', + data: Object.assign({}, settingsValue, { + achievementDelivery: { version: 1, acknowledged } + }), + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], mutation); + }, 5); + }, + async getManualState() { await ready; return kernel.read('achievements.manual'); } + }); - if (backup && backup.data) { - const restorePromises = Object.entries(backup.data).map(importEntry); - await Promise.all(restorePromises); - } + const LEGACY_DOCUMENT_ALIASES = Object.freeze({ + 'settings.values': ['user_settings', 'settings', 'system_settings'], + 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'], + 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'], + 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'], + 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'], + 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'], + 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], + 'achievements.manual': ['achievement_manual_state', 'user_achievements'] + }); + const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ + 'recovery.activeSessions', + 'recovery.drafts', + 'recovery.interrupted', + 'recovery.rejectedCompletions' + ]); + const LEGACY_PREFERENCE_ALIASES = Object.freeze({ + theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', + practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', + ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' + }); - throw importError; + function legacyRecordArray(value) { + return Array.isArray(value) ? value : asArray(asObject(value).data); + } + function mergeLegacyExternalBackup(legacyValue, externalValue) { + const legacy = Object.assign({}, asObject(legacyValue)); + const external = asObject(externalValue); + const externalRecordKey = ['practice_records', 'practiceRecords'] + .find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (externalRecordKey) { + const records = new Map(); + for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { + const recordId = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); } - } catch (error) { - console.error('Import data error:', error); - return { success: false, message: error.message }; + legacy.practice_records = Array.from(records.values()); + } + for (const [target, aliases] of Object.entries({ + user_stats: ['user_stats', 'userStats'], + exam_index: ['exam_index', 'examIndex'], + storage_version: ['storage_version', 'storageVersion'] + })) { + if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (alias) legacy[target] = clone(external[alias]); } + return legacy; } - /** - * 数据验证 - */ - validateData(key, data) { - const validators = { - practice_records: (records) => { - return Array.isArray(records) && records.every(record => - record.id && record.examId && record.startTime && record.endTime - ); - }, - user_stats: (stats) => { - return stats && typeof stats.totalPractices === 'number'; - }, - exam_index: (index) => { - return !index || (Array.isArray(index) && index.every(exam => - exam.id && exam.title && exam.category - )); - } - }; - - const validator = validators[key]; - return validator ? validator(data) : true; + function acceptedLibraryId(value) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; + try { return importedLibraryId(id); } catch (_) { return null; } } - /** - * 启动存储监控 - */ - async startStorageMonitoring() { - await this.waitForInitialization(); - console.log('[Storage] 启动存储监控...'); - - // 定期检查存储使用情况 - this.monitoringInterval = setInterval(async () => { - try { - const storageInfo = await this.getStorageInfo(); - if (storageInfo) { - const usagePercent = storageInfo.type === 'localStorage' - ? (storageInfo.used / (5 * 1024 * 1024)) * 100 - : (storageInfo.used / (105 * 1024 * 1024)) * 100; - - const maxSize = storageInfo.type === 'localStorage' ? '5MB' : - storageInfo.type === 'Hybrid' ? '105MB' : '100MB'; - console.log(`[Storage] 使用率: ${usagePercent.toFixed(2)}% (${(storageInfo.used / 1024).toFixed(2)}KB / ${maxSize})`); - - // 显示详细的存储分布 - if (storageInfo.breakdown) { - console.log(`[Storage] 存储分布: localStorage ${(storageInfo.breakdown.localStorage / 1024).toFixed(2)}KB, IndexedDB ${(storageInfo.breakdown.indexedDB / 1024).toFixed(2)}KB`); - } - - // 当使用率超过80%时,自动清理 - if (usagePercent > 80) { - console.warn('[Storage] 存储使用率过高,自动清理旧数据'); - await this.cleanupOldData(); - - // 清理后再次检查 - const newStorageInfo = await this.getStorageInfo(); - if (newStorageInfo) { - const newUsagePercent = newStorageInfo.type === 'localStorage' - ? (newStorageInfo.used / (5 * 1024 * 1024)) * 100 - : (newStorageInfo.used / (105 * 1024 * 1024)) * 100; - - console.log(`[Storage] 清理后使用率: ${newUsagePercent.toFixed(2)}%`); + function remapLegacyLibraryId(value) { + return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + } - // 如果仍然超过90%,显示警告 - if (newUsagePercent > 90) { - if (window.showMessage) { - window.showMessage('存储空间即将不足,建议导出数据备份', 'warning'); - } - } - } - } - } - } catch (error) { - console.error('[Storage] 存储监控错误:', error); - } - }, 300000); // 每5分钟检查一次 + async function migrateLegacyLibraryData(legacy) { + const [configMeta, indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.configurations', { withMeta: true }), + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const legacyIdMap = new Map(); + const indexes = {}; + const addLegacyIndex = (oldId, value) => { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; + const index = asArray(value); + if (!index.length) return; + const mappedId = remapLegacyLibraryId(oldId); + legacyIdMap.set(oldId, mappedId); + indexes[mappedId] = clone(index); + }; - // 页面卸载时清理监控 - 全局事件必须使用原生 addEventListener - window.addEventListener('beforeunload', () => { - if (this.monitoringInterval) { - clearInterval(this.monitoringInterval); + for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); + // Reconciliation is a union. A healthy current v2 value wins for the same + // deterministic library ID, while missing legacy libraries are restored. + for (const [id, value] of Object.entries(asObject(indexMeta.data))) { + if (/^exam_index_/.test(id)) addLegacyIndex(id, value); + else { + const acceptedId = acceptedLibraryId(id); + if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); + } + } + + const configurations = new Map(); + const addConfiguration = (configuration) => { + const source = asObject(configuration); + const oldId = idOf(source, ['id', 'key', 'configId']); + if (!oldId || oldId === 'exam_index') return; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + if (!id || !asArray(indexes[id]).length) return; + configurations.set(id, Object.assign({}, clone(source), { + id, + key: id, + examCount: indexes[id].length + })); + }; + asArray(legacy.exam_index_configurations).forEach(addConfiguration); + asArray(configMeta.data).forEach(addConfiguration); + for (const [oldId, id] of legacyIdMap) { + if (!configurations.has(id)) { + configurations.set(id, { + id, + key: id, + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' + }); } - }); - } - - // ==================== 词表存储专用方法 ==================== + } - /** - * 词表存储键常量 - */ - getVocabStorageKeys() { - return { - P1_ERRORS: 'vocab_list_p1_errors', - P4_ERRORS: 'vocab_list_p4_errors', - MASTER_ERRORS: 'vocab_list_master_errors', - CUSTOM: 'vocab_list_custom', - READING_HIGHLIGHTS: 'vocab_list_reading_highlights', - ACTIVE_LIST: 'vocab_active_list' + const resolveActive = (value) => { + const oldId = value === null || value === undefined ? '' : String(value).trim(); + if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + return id && asArray(indexes[id]).length ? id : null; }; - } + const currentRawActive = activeMeta.data; + let activeId = resolveActive(currentRawActive); + const currentIsExplicitDefault = Boolean(activeMeta.envelope) + && (currentRawActive === null || String(currentRawActive || '').trim() === ''); + if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { + activeId = resolveActive(legacy.active_exam_index_key); + } - /** - * 验证词表数据结构 - */ - validateVocabList(vocabList) { - if (!vocabList || typeof vocabList !== 'object') { - return { valid: false, error: '词表数据无效' }; + const nextConfigurations = Array.from(configurations.values()); + const changes = []; + if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { + changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); + } + if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { + changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); + } + if (activeId !== activeMeta.data) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); } + if (changes.length) { + await kernel.mutate(changes, { + operationId: `legacy-library-repair-v2-${checksum(changes)}` + }); + } + } - const requiredFields = ['id', 'name', 'source', 'words', 'createdAt', 'updatedAt']; - for (const field of requiredFields) { - if (!(field in vocabList)) { - return { valid: false, error: `缺少必需字段: ${field}` }; + async function migrateLegacyData() { + // Unit embedders may provide a deliberately minimal kernel bootstrap. + if (typeof internals.readLegacyValues !== 'function') return; + const legacySource = await internals.readLegacyValues(); + if (legacySource && legacySource.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); + const migrationState = asObject(migrationMeta.data); + let externalBackup = null; + if (asObject(migrationState.externalBackupV1).status !== 'consumed' + && typeof internals.readLegacyExternalBackup === 'function') { + try { + externalBackup = await internals.readLegacyExternalBackup(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } + const legacy = externalBackup + ? mergeLegacyExternalBackup(legacySource, externalBackup) + : legacySource; + if (!legacy || !Object.keys(legacy).length) return; - if (!Array.isArray(vocabList.words)) { - return { valid: false, error: 'words 字段必须是数组' }; + const documentMetas = {}; + for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { + documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); + } + const [indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const documentRepairs = []; + const poisonedDocumentKeys = []; + for (const [logicalKey, current] of Object.entries(documentMetas)) { + if (!current.envelope || current.envelope.state !== 'present') continue; + const decoded = decodePoisonedDocument(logicalKey, current.data); + if (!decoded.matched) continue; + poisonedDocumentKeys.push(logicalKey); + const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; + const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + const legacyValue = legacyAlias ? legacy[legacyAlias] : null; + let repairValue = decoded.value; + if (isPlainImportObject(legacyValue)) { + repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); + } + if (!repairValue) continue; + documentRepairs.push({ + logicalKey, + data: repairValue, + expectedRevision: Number(current.envelope.revision) + }); + } + const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => + isPlainImportObject(value) + && Object.prototype.hasOwnProperty.call(value, 'key') + && Object.prototype.hasOwnProperty.call(value, 'value') + && /^exam_system_exam_index_/.test(String(value.key || ''))); + const poisonedActive = String(activeMeta.data) === '[object Object]'; + const libraryPoisoned = poisonedIndex || poisonedActive; + const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; + + await migrateLegacyLibraryData(legacy); + if (documentRepairs.length) { + await kernel.mutate(documentRepairs, { + operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` + }); } - // 验证每个单词条目 - for (const word of vocabList.words) { - if (!word.word || typeof word.word !== 'string') { - return { valid: false, error: '单词条目缺少有效的 word 字段' }; + const changes = []; + for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { + const currentAudit = asObject(migrationState.v1ToV2); + if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { + continue; + } + const current = await kernel.getEnvelope(logicalKey); + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + if (!alias) continue; + const legacyValue = legacy[alias]; + if (!current) { + changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); + continue; + } + const isBadMigrationWrite = current.state === 'present' + && /^legacy-documents-/.test(String(current.operationId || '')); + if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { + changes.push({ + logicalKey, + data: legacyValue, + expectedRevision: Number(current.revision) + }); + continue; + } + const entry = catalog.get(logicalKey); + if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; + let merged; + try { + merged = mergeImportValue(entry, legacyValue, current.data); + } catch (error) { + if (global.console && console.warn) { + console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); + } + continue; } - if (!word.timestamp || typeof word.timestamp !== 'number') { - return { valid: false, error: '单词条目缺少有效的 timestamp 字段' }; + if (checksum(merged) !== checksum(current.data)) { + changes.push({ + logicalKey, + data: merged, + expectedRevision: Number(current.revision) + }); } } - - return { valid: true }; - } - - /** - * 清理词表数据 - * 移除重复单词,保留最新的记录 - */ - cleanVocabList(vocabList) { - if (!vocabList || !Array.isArray(vocabList.words)) { - return vocabList; + if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { + const preferences = {}; + for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) { + if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue; + const path = target.split('.'); let cursor = preferences; + path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; }); + cursor[path[path.length - 1]] = clone(legacy[alias]); + } + if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 }); + } + if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { + changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); + } + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + const recordsValue = legacy.practice_records; + const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); + const operations = []; + let skippedRecords = 0; + const reconciledRecordIds = new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + let canonical; + let parts; + try { + const candidate = jsonValue(record, 'legacy practice record'); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { + candidate.id = `legacy_${index}_${internals.checksum(record)}`; + } + canonical = canonicalizeRecord(candidate); + parts = splitPracticeRecord(canonical); + } catch (error) { + skippedRecords += 1; + if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); + continue; + } + if (reconciledRecordIds.has(canonical.id)) continue; + reconciledRecordIds.add(canonical.id); + // Storage errors are not malformed records. Let them abort this repair so the + // completion marker is not written and the next startup can retry safely. + const existing = await practiceLayers(canonical.id, true); + if (existing.summary && existing.detail && existing.annotations) continue; + operations.push(...practiceUpserts(canonical.id, parts, existing)); + } + if (operations.length) { + await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + } + const migrationAudit = { + version: 4, + status: 'complete', + mode: 'persistent-reconcile', + sourceChecksum: checksum(legacy), + sourceRecordCount: records.length, + skippedRecordCount: skippedRecords + }; + const currentAudit = asObject(migrationState.v1ToV2); + const comparableCurrentAudit = { + version: currentAudit.version, + status: currentAudit.status, + mode: currentAudit.mode, + sourceChecksum: currentAudit.sourceChecksum, + sourceRecordCount: currentAudit.sourceRecordCount, + skippedRecordCount: currentAudit.skippedRecordCount + }; + const externalAudit = externalBackup ? { + version: 1, + status: 'consumed', + sourceChecksum: checksum(externalBackup) + } : null; + const currentExternalAudit = asObject(migrationState.externalBackupV1); + if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) + || (externalAudit && (currentExternalAudit.status !== externalAudit.status + || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { + const nextMigrationState = Object.assign({}, migrationState, { + v1ToV2: Object.assign({}, migrationAudit, { + completedAt: nowIso(), + poisonDetected, + poisonedDocumentKeys, + libraryPoisoned + }) + }); + if (externalAudit) { + nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); + } + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { + operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` + }); } + } - const wordMap = new Map(); - - // 按时间戳排序,保留最新的 - vocabList.words.forEach(word => { - const key = word.word.toLowerCase().trim(); - const existing = wordMap.get(key); - - if (!existing || word.timestamp > existing.timestamp) { - wordMap.set(key, word); + const ready = kernel.initialize() + .then(async () => { + // Legacy migration and recovery cleanup are best-effort: a failure here + // (e.g. one malformed v1 record) must not brick the data layer for every + // read that awaits `ready`. Only a genuine backend init failure below is fatal. + try { + await migrateLegacyData(); + } catch (error) { + if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error); + } + try { + await cleanupExpiredRecovery(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error); } + return true; + }) + .catch((error) => { + if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error); + throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed'); }); - vocabList.words = Array.from(wordMap.values()); - vocabList.updatedAt = Date.now(); - - return vocabList; + const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements }; + Object.defineProperties(AppData, { + ready: { value: ready, enumerable: false }, + status: { value: () => kernel.status(), enumerable: false } + }); + Object.freeze(AppData); + Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false }); + if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) { + throw new Error('AppData v2 failed to close its internal bootstrap channel'); + } + if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) { + throw new Error('AppData v2 failed to close its catalog bootstrap channel'); } +})(typeof window !== 'undefined' ? window : globalThis); - /** - * 保存词表数据 - */ - async saveVocabList(vocabList, options = {}) { - const { skipReady = false } = options; - try { - // 验证数据 - const validation = this.validateVocabList(vocabList); - if (!validation.valid) { - console.error('[Storage] 词表数据验证失败:', validation.error); - return false; - } +/* ===== js/core/externalBackupService.js ===== */ +/** + * V2 external disk backup adapter. + * + * The selected directory is not a DataKernel backend. Durable application + * commits stay authoritative in AppData; this adapter writes portable v2 + * snapshots after the commit and isolates every filesystem failure. + */ +(function initExternalBackupService(global) { + 'use strict'; - // 清理数据 - const cleanedList = this.cleanVocabList(vocabList); + if (global.ExternalBackupService && global.ExternalBackupService.__v2 === true) return; - // 确定存储键 - const keys = this.getVocabStorageKeys(); - let storageKey; + var DB_NAME = 'IELTSAtlasExternalBackupV2'; + var DB_VERSION = 1; + var STORE_NAME = 'binding'; + var HANDLE_KEY = 'directory-handle'; + var META_KEY = 'metadata'; + var LATEST_FILENAME = 'ielts-atlas-backup-latest.json'; + var WRITE_DELAY_MS = 8000; + var ENTRY_ID = 'external-backup-entry-btn'; + var MODAL_ID = 'external-backup-modal'; - switch (cleanedList.source) { - case 'p1': - storageKey = keys.P1_ERRORS; - break; - case 'p4': - storageKey = keys.P4_ERRORS; - break; - case 'all': - storageKey = keys.MASTER_ERRORS; - break; - case 'user': - storageKey = keys.CUSTOM; - break; - case 'reading-highlight': - storageKey = keys.READING_HIGHLIGHTS; - break; - default: - storageKey = cleanedList.id; - } + var state = { + ready: false, + readyPromise: null, + initialized: false, + suspended: false, + directoryHandle: null, + permission: 'prompt', + dirty: false, + dirtyGeneration: 0, + writing: false, + writeQueue: Promise.resolve(), + silentFlushTimer: null, + unsubscribeCommitted: null, + visibilityHandler: null, + meta: { + directoryName: null, + lastWriteAt: null, + lastChecksum: null, + lastWriteError: null, + awaitingRestore: false + } + }; - console.log(`[Storage] 保存词表: ${storageKey}, 单词数: ${cleanedList.words.length}`); + function nowIso() { + return new Date().toISOString(); + } - // 保存到存储 - const success = await this.set(storageKey, cleanedList, { skipReady }); + function dayKey(date) { + var year = date.getFullYear(); + var month = String(date.getMonth() + 1).padStart(2, '0'); + var day = String(date.getDate()).padStart(2, '0'); + return year + '-' + month + '-' + day; + } - if (success) { - console.log(`[Storage] 词表保存成功: ${storageKey}`); - } + function cloneMeta(value) { + var source = value && typeof value === 'object' ? value : {}; + return { + directoryName: source.directoryName ? String(source.directoryName) : null, + lastWriteAt: source.lastWriteAt ? String(source.lastWriteAt) : null, + lastChecksum: source.lastChecksum ? String(source.lastChecksum) : null, + lastWriteError: source.lastWriteError ? String(source.lastWriteError) : null, + awaitingRestore: source.awaitingRestore === true + }; + } - return success; - } catch (error) { - console.error('[Storage] 保存词表失败:', error); - return false; + function getIndexedDB() { + try { + return global.indexedDB || null; + } catch (_) { + return null; } } - /** - * 加载词表数据 - */ - async loadVocabList(listId, options = {}) { - const { skipReady = false } = options; + function supportsFileSystemAccess() { + return typeof global.showDirectoryPicker === 'function' + && global.isSecureContext !== false; + } - try { - const keys = this.getVocabStorageKeys(); - let storageKey; - - // 根据 listId 确定存储键 - if (listId === 'spelling-errors-p1') { - storageKey = keys.P1_ERRORS; - } else if (listId === 'spelling-errors-p4') { - storageKey = keys.P4_ERRORS; - } else if (listId === 'spelling-errors-master') { - storageKey = keys.MASTER_ERRORS; - } else if (listId === 'custom') { - storageKey = keys.CUSTOM; - } else if (listId === 'reading-highlights') { - storageKey = keys.READING_HIGHLIGHTS; - } else { - storageKey = listId; - } - - console.log(`[Storage] 加载词表: ${storageKey}`); - - const vocabList = await this.get(storageKey, null, { skipReady }); - - if (!vocabList) { - console.log(`[Storage] 词表不存在: ${storageKey}`); - return null; + function openBindingDb() { + return new Promise(function (resolve, reject) { + var indexedDb = getIndexedDB(); + if (!indexedDb) { + reject(new Error('IndexedDB unavailable for directory binding')); + return; } - - if (Array.isArray(vocabList)) { - const now = new Date().toISOString(); - const sourceMap = { - 'spelling-errors-p1': 'p1', - 'spelling-errors-p4': 'p4', - 'spelling-errors-master': 'all', - 'custom': 'user', - 'reading-highlights': 'reading-highlight' - }; - const nameMap = { - 'spelling-errors-p1': 'P1 拼写错误', - 'spelling-errors-p4': 'P4 拼写错误', - 'spelling-errors-master': '综合错误词表', - 'custom': '自定义词表', - 'reading-highlights': '阅读高亮生词' - }; - return { - id: listId, - name: nameMap[listId] || listId, - source: sourceMap[listId] || listId, - words: vocabList, - createdAt: now, - updatedAt: now - }; + var request; + try { + request = indexedDb.open(DB_NAME, DB_VERSION); + } catch (error) { + reject(error); + return; } + request.onerror = function () { + reject(request.error || new Error('Failed to open external backup binding database')); + }; + request.onupgradeneeded = function (event) { + var db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); + }; + request.onsuccess = function () { + resolve(request.result); + }; + }); + } - // 验证加载的数据 - const validation = this.validateVocabList(vocabList); - if (!validation.valid) { - console.error('[Storage] 加载的词表数据无效:', validation.error); - return null; - } + async function readStoredValue(key) { + var db = await openBindingDb(); + try { + return await new Promise(function (resolve, reject) { + var tx = db.transaction(STORE_NAME, 'readonly'); + var request = tx.objectStore(STORE_NAME).get(key); + request.onsuccess = function () { resolve(request.result); }; + request.onerror = function () { reject(request.error || tx.error); }; + tx.onabort = function () { reject(tx.error || new Error('Binding read transaction aborted')); }; + }); + } finally { + try { db.close(); } catch (_) { /* ignore */ } + } + } - console.log(`[Storage] 词表加载成功: ${storageKey}, 单词数: ${vocabList.words.length}`); - return vocabList; - } catch (error) { - console.error('[Storage] 加载词表失败:', error); - return null; + async function writeStoredValues(values) { + var db = await openBindingDb(); + try { + await new Promise(function (resolve, reject) { + var tx = db.transaction(STORE_NAME, 'readwrite'); + var store = tx.objectStore(STORE_NAME); + Object.keys(values).forEach(function (key) { + store.put(values[key], key); + }); + tx.oncomplete = function () { resolve(); }; + tx.onerror = function () { reject(tx.error || new Error('Binding write transaction failed')); }; + tx.onabort = function () { reject(tx.error || new Error('Binding write transaction aborted')); }; + }); + } finally { + try { db.close(); } catch (_) { /* ignore */ } } } - /** - * 获取词表单词数量 - */ - async getVocabListWordCount(listId, options = {}) { - const { skipReady = false } = options; + async function clearStoredBinding() { + var db = await openBindingDb(); + try { + await new Promise(function (resolve, reject) { + var tx = db.transaction(STORE_NAME, 'readwrite'); + var store = tx.objectStore(STORE_NAME); + store.delete(HANDLE_KEY); + store.delete(META_KEY); + tx.oncomplete = function () { resolve(); }; + tx.onerror = function () { reject(tx.error || new Error('Binding clear transaction failed')); }; + tx.onabort = function () { reject(tx.error || new Error('Binding clear transaction aborted')); }; + }); + } finally { + try { db.close(); } catch (_) { /* ignore */ } + } + } + async function persistMeta(patch) { + if (state.suspended) return false; + state.meta = Object.assign({}, state.meta, cloneMeta(Object.assign({}, state.meta, patch || {}))); try { - const vocabList = await this.loadVocabList(listId, { skipReady }); - return vocabList ? vocabList.words.length : 0; + await writeStoredValues((function () { + var values = {}; + values[META_KEY] = state.meta; + return values; + })()); } catch (error) { - console.error('[Storage] 获取词表单词数量失败:', error); - return 0; + if (global.console && console.warn) console.warn('[ExternalBackup v2] metadata persistence failed:', error); } + return true; } - /** - * 添加单词到词表 - */ - async addWordToVocabList(listId, word, options = {}) { - const { skipReady = false } = options; - + async function queryPermission(handle, mode) { + if (!handle) return 'denied'; try { - let vocabList = await this.loadVocabList(listId, { skipReady }); - - if (!vocabList) { - // 创建新词表 - vocabList = { - id: listId, - name: this.getVocabListName(listId), - source: this.getVocabListSource(listId), - words: [], - createdAt: Date.now(), - updatedAt: Date.now() - }; + if (typeof handle.queryPermission === 'function') { + return await handle.queryPermission({ mode: mode || 'readwrite' }); } + } catch (_) { /* ignore */ } + return 'prompt'; + } - // 检查单词是否已存在 - const existingIndex = vocabList.words.findIndex(w => - w.word.toLowerCase() === word.word.toLowerCase() - ); - - if (existingIndex >= 0) { - // 更新现有单词 - vocabList.words[existingIndex] = { - ...vocabList.words[existingIndex], - ...word, - errorCount: (vocabList.words[existingIndex].errorCount || 0) + 1, - timestamp: Date.now() - }; - } else { - // 添加新单词 - vocabList.words.push({ - ...word, - errorCount: word.errorCount || 1, - timestamp: word.timestamp || Date.now() - }); + async function ensurePermission(handle, interactive) { + var permission = await queryPermission(handle, 'readwrite'); + if (permission === 'granted') { + state.permission = permission; + return true; + } + if (!interactive) { + state.permission = permission; + return false; + } + try { + if (typeof handle.requestPermission === 'function') { + permission = await handle.requestPermission({ mode: 'readwrite' }); } + } catch (_) { + permission = 'denied'; + } + state.permission = permission; + return permission === 'granted'; + } - vocabList.updatedAt = Date.now(); - - return await this.saveVocabList(vocabList, { skipReady }); - } catch (error) { - console.error('[Storage] 添加单词到词表失败:', error); + async function requestPersistentStorage() { + try { + var storage = global.navigator && global.navigator.storage; + if (!storage || typeof storage.persist !== 'function') return false; + if (typeof storage.persisted === 'function' && await storage.persisted()) return true; + return await storage.persist(); + } catch (_) { return false; } } - /** - * 从词表中移除单词 - */ - async removeWordFromVocabList(listId, word, options = {}) { - const { skipReady = false } = options; - + async function writeAndVerify(directoryHandle, filename, text, snapshot) { + var fileHandle = await directoryHandle.getFileHandle(filename, { create: true }); + var writable = await fileHandle.createWritable(); try { - const vocabList = await this.loadVocabList(listId, { skipReady }); - - if (!vocabList) { - return false; - } - - const normalizedWord = word.toLowerCase().trim(); - vocabList.words = vocabList.words.filter(w => - w.word.toLowerCase().trim() !== normalizedWord - ); - - vocabList.updatedAt = Date.now(); + await writable.write(text); + await writable.close(); + } catch (error) { + try { await writable.abort(); } catch (_) { /* ignore */ } + throw error; + } - return await this.saveVocabList(vocabList, { skipReady }); + var file = await fileHandle.getFile(); + var storedText = await file.text(); + var stored; + try { + stored = JSON.parse(storedText); } catch (error) { - console.error('[Storage] 从词表移除单词失败:', error); - return false; + throw new Error('Backup verification failed: written file is not valid JSON'); + } + if (!stored || stored.format !== 'ielts-atlas-data-v2' + || stored.schemaVersion !== snapshot.schemaVersion + || stored.checksum !== snapshot.checksum) { + throw new Error('Backup verification failed: snapshot metadata mismatch'); } + return storedText.length; } - /** - * 获取词表名称 - */ - getVocabListName(listId) { - const names = { - 'spelling-errors-p1': 'P1 拼写错误', - 'spelling-errors-p4': 'P4 拼写错误', - 'spelling-errors-master': '综合错误词表', - 'custom': '自定义词表' - }; - return names[listId] || listId; + async function fileExists(directoryHandle, filename) { + try { + await directoryHandle.getFileHandle(filename, { create: false }); + return true; + } catch (error) { + if (error && error.name === 'NotFoundError') return false; + throw error; + } } - /** - * 获取词表来源 - */ - getVocabListSource(listId) { - if (listId.includes('p1')) return 'p1'; - if (listId.includes('p4')) return 'p4'; - if (listId.includes('master')) return 'all'; - return 'user'; + function uniqueGenerationFilename(date) { + var time = [ + String(date.getHours()).padStart(2, '0'), + String(date.getMinutes()).padStart(2, '0'), + String(date.getSeconds()).padStart(2, '0'), + String(date.getMilliseconds()).padStart(3, '0') + ].join(''); + return 'ielts-atlas-backup-' + dayKey(date) + '-' + time + '.json'; } - /** - * 获取所有词表的元数据 - */ - async getAllVocabListsMetadata(options = {}) { - const { skipReady = false } = options; - - const keys = this.getVocabStorageKeys(); - const listIds = [ - 'spelling-errors-p1', - 'spelling-errors-p4', - 'spelling-errors-master', - 'custom' - ]; - - const metadata = []; - - for (const listId of listIds) { - const count = await this.getVocabListWordCount(listId, { skipReady }); - metadata.push({ - id: listId, - name: this.getVocabListName(listId), - source: this.getVocabListSource(listId), - wordCount: count - }); + function requireBackupApi() { + var backups = global.AppData && global.AppData.backups; + if (!backups || typeof backups.export !== 'function' + || typeof backups.previewImport !== 'function' + || typeof backups.commitImport !== 'function') { + throw new Error('AppData v2 backup API is unavailable'); } - - return metadata; + return backups; } - // ==================== 数据同步逻辑 ==================== - - /** - * 同步词表数据(跨会话) - * 处理数据冲突,使用最新时间戳 - */ - async syncVocabList(listId, newData, options = {}) { - const { skipReady = false } = options; - + async function withDiskWriteLock(callback) { + var previous = state.writeQueue.catch(function () {}); + var releaseCurrent; + state.writeQueue = new Promise(function (resolve) { + releaseCurrent = resolve; + }); + await previous; try { - console.log(`[Storage] 开始同步词表: ${listId}`); - - // 加载现有数据 - const existingList = await this.loadVocabList(listId, { skipReady }); - - if (!existingList) { - // 没有现有数据,直接保存新数据 - console.log(`[Storage] 无现有数据,直接保存新词表`); - return await this.saveVocabList(newData, { skipReady }); + var locks = global.navigator && global.navigator.locks; + if (locks && typeof locks.request === 'function') { + return await locks.request('ielts-atlas-external-backup-write', { mode: 'exclusive' }, callback); } - - // 合并数据,解决冲突 - const mergedList = this.mergeVocabLists(existingList, newData); - - console.log(`[Storage] 词表合并完成,单词数: ${mergedList.words.length}`); - - // 保存合并后的数据 - return await this.saveVocabList(mergedList, { skipReady }); - } catch (error) { - console.error('[Storage] 同步词表失败:', error); - return false; + return await callback(); + } finally { + releaseCurrent(); } } - /** - * 合并两个词表,解决冲突 - * 使用最新时间戳的数据 - */ - mergeVocabLists(existing, incoming) { - // 使用最新的元数据 - const merged = { - id: existing.id, - name: existing.name, - source: existing.source, - words: [], - createdAt: existing.createdAt, - updatedAt: Math.max(existing.updatedAt, incoming.updatedAt) - }; - - // 创建单词映射 - const wordMap = new Map(); + async function refreshStoredBindingForWrite() { + if (state.suspended) return; + var stored = await Promise.all([ + readStoredValue(HANDLE_KEY), + readStoredValue(META_KEY) + ]); + state.directoryHandle = stored[0] || null; + if (stored[1]) state.meta = cloneMeta(stored[1]); + if (state.directoryHandle && !state.meta.directoryName) { + state.meta.directoryName = state.directoryHandle.name || 'backup'; + } + } - // 先添加现有单词 - existing.words.forEach(word => { - const key = word.word.toLowerCase().trim(); - wordMap.set(key, word); - }); + async function writeToBoundDirectory(options) { + var opts = options || {}; + await ensureReady(); + if (state.suspended) return { success: false, reason: 'suspended' }; + return withDiskWriteLock(async function () { + if (state.suspended) return { success: false, reason: 'suspended' }; + if (state.writing) return { success: false, reason: 'busy' }; + try { + await refreshStoredBindingForWrite(); + } catch (error) { + return { success: false, reason: 'binding_unavailable', error: error }; + } + if (!state.directoryHandle) return { success: false, reason: 'unbound' }; + if (state.meta.awaitingRestore && opts.allowOverwriteExisting !== true) { + return { success: false, reason: 'restore_required' }; + } - // 合并新单词,使用最新时间戳 - incoming.words.forEach(word => { - const key = word.word.toLowerCase().trim(); - const existingWord = wordMap.get(key); - - if (!existingWord || word.timestamp > existingWord.timestamp) { - // 新单词或更新的单词 - wordMap.set(key, { - ...existingWord, - ...word, - errorCount: (existingWord?.errorCount || 0) + (word.errorCount || 1) + var startedGeneration = state.dirtyGeneration; + var followupNeeded = false; + state.writing = true; + refreshPanel(); + try { + if (!await ensurePermission(state.directoryHandle, opts.interactive === true)) { + await persistMeta({ lastWriteError: 'permission_denied' }); + return { success: false, reason: 'permission_denied' }; + } + + var backups = requireBackupApi(); + var snapshot = await backups.export(); + if (!snapshot || snapshot.format !== 'ielts-atlas-data-v2' || !snapshot.checksum) { + throw new Error('AppData returned an invalid v2 backup snapshot'); + } + if (!opts.force && snapshot.checksum === state.meta.lastChecksum) { + state.dirty = state.dirtyGeneration !== startedGeneration; + followupNeeded = state.dirty; + return { success: true, reason: 'unchanged', skipped: true, checksum: snapshot.checksum }; + } + + var text = JSON.stringify(snapshot, null, 2); + var writeDate = new Date(); + var datedFilename = 'ielts-atlas-backup-' + dayKey(writeDate) + '.json'; + if (await fileExists(state.directoryHandle, datedFilename)) { + datedFilename = uniqueGenerationFilename(writeDate); + } + await writeAndVerify(state.directoryHandle, datedFilename, text, snapshot); + var bytes = await writeAndVerify(state.directoryHandle, LATEST_FILENAME, text, snapshot); + + var latestSnapshot = await backups.export(); + var changedDuringWrite = state.dirtyGeneration !== startedGeneration + || !latestSnapshot || latestSnapshot.checksum !== snapshot.checksum; + if (changedDuringWrite && state.dirtyGeneration === startedGeneration) { + state.dirtyGeneration += 1; + } + state.dirty = changedDuringWrite; + followupNeeded = changedDuringWrite; + await persistMeta({ + directoryName: state.directoryHandle.name || state.meta.directoryName || 'backup', + lastWriteAt: nowIso(), + lastChecksum: snapshot.checksum, + lastWriteError: null }); + return { + success: true, + reason: 'written', + filename: LATEST_FILENAME, + generationFilename: datedFilename, + checksum: snapshot.checksum, + bytes: bytes, + followupPending: followupNeeded + }; + } catch (error) { + followupNeeded = state.dirtyGeneration !== startedGeneration; + await persistMeta({ lastWriteError: error && error.message ? error.message : String(error) }); + if (global.console && console.error) console.error('[ExternalBackup v2] write failed:', error); + return { success: false, reason: 'write_error', error: error }; + } finally { + state.writing = false; + if (followupNeeded && state.directoryHandle) scheduleSilentFlush(); + refreshPanel(); } }); - - merged.words = Array.from(wordMap.values()); - - return merged; } - /** - * 批量同步所有词表 - */ - async syncAllVocabLists(options = {}) { - const { skipReady = false } = options; - - try { - console.log('[Storage] 开始批量同步所有词表'); - - const listIds = [ - 'spelling-errors-p1', - 'spelling-errors-p4', - 'spelling-errors-master', - 'custom' - ]; - - const results = []; + async function bindDirectory(options) { + if (state.suspended) throw new Error('本地备份服务正在重置'); + if (!supportsFileSystemAccess()) { + throw new Error('当前浏览器不支持绑定本地文件夹(请使用 Chrome/Edge 并通过 http(s) 或 localhost 打开)'); + } + var handle = await global.showDirectoryPicker({ + id: 'ielts-atlas-external-backup', + mode: 'readwrite', + startIn: 'documents' + }); + if (!handle) throw new Error('未选择文件夹'); + if (!await ensurePermission(handle, true)) throw new Error('未获得文件夹读写权限'); - for (const listId of listIds) { - const list = await this.loadVocabList(listId, { skipReady }); - if (list) { - const success = await this.syncVocabList(listId, list, { skipReady }); - results.push({ listId, success }); - } - } + var existingBackupFound = await fileExists(handle, LATEST_FILENAME); + var meta = cloneMeta({ + directoryName: handle.name || 'backup', + lastWriteAt: null, + lastChecksum: null, + lastWriteError: null, + awaitingRestore: existingBackupFound + }); + var values = {}; + values[HANDLE_KEY] = handle; + values[META_KEY] = meta; + await writeStoredValues(values); + state.directoryHandle = handle; + state.meta = meta; + state.dirty = !existingBackupFound; + state.dirtyGeneration += 1; + await requestPersistentStorage(); - console.log('[Storage] 批量同步完成:', results); - return results; - } catch (error) { - console.error('[Storage] 批量同步失败:', error); - return []; + var writeResult = null; + if (!existingBackupFound && (!options || options.writeNow !== false)) { + writeResult = await writeToBoundDirectory({ interactive: true, force: true }); } + refreshPanel(); + return { + directoryName: meta.directoryName, + existingBackupFound: existingBackupFound, + writeResult: writeResult + }; } - /** - * 确保数据持久化(页面关闭前) - */ - async ensureDataPersisted(options = {}) { - const { skipReady = false } = options; - - try { - console.log('[Storage] 确保数据持久化'); - - // 强制刷新所有待写入的数据 - if (this.indexedDB) { - // IndexedDB 事务会自动提交,无需额外操作 - console.log('[Storage] IndexedDB 数据已自动持久化'); - } - - // 同步所有词表 - await this.syncAllVocabLists({ skipReady }); - - console.log('[Storage] 数据持久化完成'); - return true; - } catch (error) { - console.error('[Storage] 数据持久化失败:', error); - return false; + function cancelSilentFlush() { + if (state.silentFlushTimer) { + global.clearTimeout(state.silentFlushTimer); + state.silentFlushTimer = null; } } - /** - * 监听页面卸载事件,确保数据持久化 - */ - setupBeforeUnloadHandler() { - // 使用 beforeunload 事件确保数据保存 - window.addEventListener('beforeunload', async (event) => { - try { - console.log('[Storage] 页面即将关闭,确保数据持久化'); - - // 同步保存所有待写入的数据 - await this.ensureDataPersisted({ skipReady: true }); + function clearBindingState() { + state.directoryHandle = null; + state.permission = 'prompt'; + state.dirty = false; + state.dirtyGeneration += 1; + state.meta = cloneMeta({}); + refreshPanel(); + } - console.log('[Storage] 数据持久化完成'); - } catch (error) { - console.error('[Storage] beforeunload 数据持久化失败:', error); - } + async function unbindDirectory() { + cancelSilentFlush(); + await withDiskWriteLock(async function () { + await clearStoredBinding(); + clearBindingState(); }); - - console.log('[Storage] beforeunload 处理器已设置'); + return true; } - /** - * 检测数据冲突 - */ - detectVocabListConflict(list1, list2) { - if (!list1 || !list2) return false; - - // 检查是否有相同单词但不同内容 - const conflicts = []; - - const map1 = new Map(list1.words.map(w => [w.word.toLowerCase(), w])); - const map2 = new Map(list2.words.map(w => [w.word.toLowerCase(), w])); - - for (const [word, data1] of map1) { - const data2 = map2.get(word); - if (data2 && data1.timestamp !== data2.timestamp) { - conflicts.push({ - word, - data1, - data2, - resolution: data1.timestamp > data2.timestamp ? 'use_list1' : 'use_list2' - }); - } + async function prepareForFullReset() { + state.suspended = true; + cancelSilentFlush(); + if (typeof state.unsubscribeCommitted === 'function') { + try { state.unsubscribeCommitted(); } catch (_) { /* ignore */ } } - - return conflicts.length > 0 ? conflicts : false; - } - - /** - * 解决词表冲突 - */ - resolveVocabListConflict(list1, list2, strategy = 'latest') { - if (strategy === 'latest') { - return this.mergeVocabLists(list1, list2); - } else if (strategy === 'keep_list1') { - return list1; - } else if (strategy === 'keep_list2') { - return list2; + state.unsubscribeCommitted = null; + if (global.document && state.visibilityHandler) { + try { global.document.removeEventListener('visibilitychange', state.visibilityHandler); } catch (_) { /* ignore */ } } + state.visibilityHandler = null; + state.initialized = false; - return this.mergeVocabLists(list1, list2); + await withDiskWriteLock(async function () { + await clearStoredBinding(); + clearBindingState(); + }); + return { + success: true, + diskFilesPreserved: true, + bindingCleared: true + }; } - // ==================== 降级存储方案 ==================== - - /** - * 检测 IndexedDB 可用性 - */ - isIndexedDBAvailable() { + async function readLatestPayload(interactive) { + await ensureReady(); + if (!state.directoryHandle) throw new Error('请先绑定备份文件夹'); + if (!await ensurePermission(state.directoryHandle, interactive === true)) { + throw new Error('需要允许文件夹访问权限'); + } + var fileHandle; try { - // 检查浏览器是否支持 IndexedDB - if (!window.indexedDB) { - console.log('[Storage] IndexedDB 不支持'); - return false; - } - - // 检查是否已成功初始化 - if (this.indexedDB) { - console.log('[Storage] IndexedDB 可用'); - return true; - } - - console.log('[Storage] IndexedDB 未初始化'); - return false; + fileHandle = await state.directoryHandle.getFileHandle(LATEST_FILENAME, { create: false }); } catch (error) { - console.error('[Storage] IndexedDB 可用性检测失败:', error); - return false; + throw new Error('未找到 ' + LATEST_FILENAME); } - } - - /** - * 检测 localStorage 可用性 - */ - isLocalStorageAvailable() { + var file = await fileHandle.getFile(); + var text = await file.text(); try { - const testKey = '__storage_test__'; - localStorage.setItem(testKey, 'test'); - localStorage.removeItem(testKey); - console.log('[Storage] localStorage 可用'); - return true; - } catch (error) { - console.error('[Storage] localStorage 不可用:', error); - return false; + return JSON.parse(text); + } catch (_) { + throw new Error('本地备份文件不是有效的 JSON'); } } - /** - * 获取当前存储类型 - */ - getCurrentStorageType() { - if (this.fallbackStorage) { - return 'memory'; - } else if (this.indexedDB) { - return 'indexedDB'; - } else if (this.isLocalStorageAvailable()) { - return 'localStorage'; + function summarizePreview(preview) { + var keys = Array.isArray(preview.keys) ? preview.keys : []; + var cleared = Array.isArray(preview.clearedKeys) ? preview.clearedKeys : []; + var practice = preview.practice || {}; + var lines = [ + '将从本地磁盘备份覆盖恢复当前数据。', + '格式:' + (preview.format || 'unknown') + (preview.scope ? ' / ' + preview.scope : ''), + '数据域:' + (keys.length ? keys.join('、') : '无') + ]; + if (cleared.length) lines.push('将清空:' + cleared.join('、')); + if (practice && Number.isFinite(Number(practice.finalCount))) { + lines.push('练习记录:现有 ' + (Number(practice.existingCount) || 0) + + ' 条 → 恢复后 ' + Number(practice.finalCount) + ' 条' + + '(删除 ' + (Number(practice.removedCount) || 0) + ' 条)'); + } + var diagnostics = preview.diagnostics || {}; + if (Array.isArray(diagnostics.missingKeys) && diagnostics.missingKeys.length) { + lines.push('备份缺失且将保留现状:' + diagnostics.missingKeys.join('、')); + } + if (Array.isArray(diagnostics.repairedKeys) && diagnostics.repairedKeys.length) { + lines.push('已修复旧格式数据:' + diagnostics.repairedKeys.join('、')); } - return 'none'; + if (Array.isArray(diagnostics.ignoredKeys) && diagnostics.ignoredKeys.length) { + lines.push('已隔离不安全数据:' + diagnostics.ignoredKeys.join('、')); + } + if (Array.isArray(preview.warnings) && preview.warnings.length) { + lines.push('警告:' + preview.warnings.join(';')); + } + lines.push('', '恢复前会创建一个应用内安全快照。是否继续?'); + return lines.join('\n'); } - /** - * 处理存储空间不足 - */ - async handleStorageQuotaExceeded(key, value, options = {}) { - console.warn('[Storage] 存储空间不足,尝试清理'); - + function createOperationId(prefix) { try { - if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) { - console.error(`[Storage] ${key} 空间不足时禁止 raw fallback`); - if (window.showMessage) { - window.showMessage('练习数据保存空间不足,请先导出备份并清理空间', 'error'); - } - return false; + if (global.crypto && typeof global.crypto.randomUUID === 'function') { + return prefix + '-' + global.crypto.randomUUID(); } + } catch (_) { /* ignore */ } + return prefix + '-' + Date.now() + '-' + Math.random().toString(16).slice(2); + } - // 1. 清理旧数据 - await this.cleanupOldData({ skipReady: true }); - - // 2. 再次尝试保存 - const retrySuccess = await this.set(key, value, { skipReady: true }); - if (retrySuccess) { - console.log('[Storage] 清理后保存成功'); - return true; + async function restorePayload(payload, options) { + var opts = options || {}; + var backups = requireBackupApi(); + var preview = await backups.previewImport(payload, { + replace: true, + practiceMode: 'replace', + applyClears: true, + fullRestore: true + }); + var confirmed = opts.confirmed === true; + if (!confirmed) { + try { + confirmed = global.confirm(summarizePreview(preview)); + } catch (_) { + confirmed = false; } + } + if (!confirmed) return { success: false, reason: 'cancelled', preview: preview }; - // 3. 如果仍然失败,尝试降级存储 - console.warn('[Storage] 清理后仍然失败,尝试降级存储'); - - const storageType = this.getCurrentStorageType(); - - if (storageType === 'indexedDB') { - // 降级到 localStorage - console.log('[Storage] 从 IndexedDB 降级到 localStorage'); - try { - const serializedValue = JSON.stringify({ - data: value, - timestamp: Date.now(), - version: this.version - }); - localStorage.setItem(this.getKey(key), serializedValue); - console.log('[Storage] localStorage 保存成功'); - return true; - } catch (localStorageError) { - console.error('[Storage] localStorage 保存失败:', localStorageError); - } + await backups.create({ + type: 'pre-external-restore', + operationId: createOperationId('pre-external-restore') + }); + var result = await backups.commitImport(preview.id, { + operationId: opts.operationId || createOperationId('external-restore'), + confirmDestructive: preview.destructive === true + }); + try { + if (typeof backups.recordImport === 'function') { + await backups.recordImport({ + source: 'external-backup', + format: preview.format, + keys: preview.keys, + clearedKeys: preview.clearedKeys, + practice: preview.practice || null + }); } + } catch (historyError) { + if (global.console && console.warn) console.warn('[ExternalBackup v2] import history failed:', historyError); + } + return { success: true, preview: preview, result: result }; + } - // 4. 最后降级到内存存储 - console.warn('[Storage] 降级到内存存储'); - if (!this.fallbackStorage) { - this.fallbackStorage = new Map(); - } - const serializedValue = JSON.stringify({ - data: value, - timestamp: Date.now(), - version: this.version + async function restoreFromLatest(options) { + var payload = await readLatestPayload(true); + var result = await restorePayload(payload, options); + if (result && result.success) { + state.dirty = false; + await persistMeta({ + lastChecksum: payload && payload.checksum ? payload.checksum : state.meta.lastChecksum, + lastWriteError: null, + awaitingRestore: false }); - this.fallbackStorage.set(this.getKey(key), serializedValue); - - // 提示用户 - if (window.showMessage) { - window.showMessage('存储空间不足,数据已保存到临时存储,请导出备份', 'warning'); - } - - return true; - } catch (error) { - console.error('[Storage] 处理存储空间不足失败:', error); - - // 最终失败,提示用户 - if (window.showMessage) { - window.showMessage('存储空间严重不足,无法保存数据,请清理旧数据', 'error'); - } - - return false; } + return result; } - /** - * 词表专用降级保存 - */ - async saveVocabListWithFallback(vocabList, options = {}) { - const { skipReady = false } = options; - - try { - // 首先尝试正常保存 - const success = await this.saveVocabList(vocabList, { skipReady }); - - if (success) { - return true; - } - - // 如果失败,尝试降级保存 - console.warn('[Storage] 词表保存失败,尝试降级保存'); - - // 压缩词表数据 - const compressedList = this.compressVocabList(vocabList); - - // 再次尝试保存压缩后的数据 - const compressedSuccess = await this.saveVocabList(compressedList, { skipReady }); + function scheduleSilentFlush() { + if (state.suspended || state.meta.awaitingRestore) return; + if (state.silentFlushTimer) global.clearTimeout(state.silentFlushTimer); + state.silentFlushTimer = global.setTimeout(function () { + state.silentFlushTimer = null; + return flushSilentlyIfPermitted().catch(function (error) { + if (global.console && console.warn) console.warn('[ExternalBackup v2] silent flush failed:', error); + }); + }, WRITE_DELAY_MS); + } - if (compressedSuccess) { - console.log('[Storage] 压缩后保存成功'); - return true; - } + function markDirty() { + if (state.suspended) return; + state.dirty = true; + state.dirtyGeneration += 1; + refreshPanel(); + scheduleSilentFlush(); + } - // 如果仍然失败,使用降级存储 - return await this.handleStorageQuotaExceeded( - this.getVocabStorageKey(vocabList.id), - compressedList - ); - } catch (error) { - console.error('[Storage] 词表降级保存失败:', error); - return false; + async function flushSilentlyIfPermitted() { + await ensureReady(); + if (state.suspended) return { success: false, reason: 'suspended' }; + if (state.meta.awaitingRestore) return { success: false, reason: 'restore_required' }; + if (!state.directoryHandle || !state.dirty) { + return { success: false, reason: 'skip' }; + } + if (state.writing) { + scheduleSilentFlush(); + return { success: false, reason: 'busy' }; } + if (!await ensurePermission(state.directoryHandle, false)) { + refreshPanel(); + return { success: false, reason: 'permission_denied' }; + } + return writeToBoundDirectory({ interactive: false, force: false }); } - /** - * 压缩词表数据 - */ - compressVocabList(vocabList) { + function getStatus() { return { - id: vocabList.id, - name: vocabList.name, - source: vocabList.source, - words: vocabList.words.map(word => ({ - word: word.word, - userInput: word.userInput, - timestamp: word.timestamp, - errorCount: word.errorCount - // 移除其他非必要字段 - })), - createdAt: vocabList.createdAt, - updatedAt: vocabList.updatedAt + supported: supportsFileSystemAccess(), + bound: !!state.directoryHandle, + directoryName: state.meta.directoryName, + permission: state.permission, + permissionGranted: state.permission === 'granted', + dirty: state.dirty, + writing: state.writing, + suspended: state.suspended, + lastWriteAt: state.meta.lastWriteAt, + lastChecksum: state.meta.lastChecksum, + lastWriteError: state.meta.lastWriteError, + awaitingRestore: state.meta.awaitingRestore }; } - /** - * 获取词表存储键 - */ - getVocabStorageKey(listId) { - const keys = this.getVocabStorageKeys(); - - if (listId === 'spelling-errors-p1') return keys.P1_ERRORS; - if (listId === 'spelling-errors-p4') return keys.P4_ERRORS; - if (listId === 'spelling-errors-master') return keys.MASTER_ERRORS; - if (listId === 'custom') return keys.CUSTOM; - - return listId; + function formatTime(value) { + if (!value) return ''; + var parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString(); } - /** - * 检查存储健康状态 - */ - async checkStorageHealth(options = {}) { - const { skipReady = false } = options; - - try { - const health = { - indexedDB: this.isIndexedDBAvailable(), - localStorage: this.isLocalStorageAvailable(), - currentType: this.getCurrentStorageType(), - quotaStatus: 'unknown' - }; + function formatStatusText(status) { + if (!status.supported) return '当前环境不支持文件夹绑定,请使用「导出到下载」和「导入数据」。'; + if (!status.bound) return '未绑定本地备份文件夹。'; + var parts = ['已绑定:' + (status.directoryName || '文件夹')]; + if (status.awaitingRestore) parts.push('检测到已有备份,请先恢复'); + if (!status.permissionGranted) parts.push('需要重新授权'); + if (status.writing) parts.push('正在写入'); + else if (status.lastWriteAt) parts.push('上次写入 ' + formatTime(status.lastWriteAt)); + else parts.push('尚未写入'); + if (status.dirty) parts.push('有未备份的新数据'); + if (status.lastWriteError) parts.push('最近错误:' + status.lastWriteError); + return parts.join(' · '); + } - // 检查配额状态 - const storageInfo = await this.getStorageInfo({ skipReady }); - if (storageInfo) { - const usagePercent = storageInfo.type === 'localStorage' - ? (storageInfo.used / (5 * 1024 * 1024)) * 100 - : (storageInfo.used / (105 * 1024 * 1024)) * 100; - - if (usagePercent < 70) { - health.quotaStatus = 'healthy'; - } else if (usagePercent < 90) { - health.quotaStatus = 'warning'; - } else { - health.quotaStatus = 'critical'; - } + function notify(message, type) { + if (typeof global.showMessage === 'function') { + global.showMessage(message, type || 'info'); + } else if (global.console && console.log) { + console.log('[ExternalBackup v2] ' + message); + } + } - health.usagePercent = usagePercent; - health.used = storageInfo.used; - } + function makeButton(id, label) { + var button = global.document.createElement('button'); + button.type = 'button'; + button.id = id; + button.className = 'btn data-mgmt-btn'; + button.textContent = label; + return button; + } - console.log('[Storage] 存储健康状态:', health); - return health; - } catch (error) { - console.error('[Storage] 检查存储健康状态失败:', error); - return { - indexedDB: false, - localStorage: false, - currentType: 'none', - quotaStatus: 'error' - }; - } + function getModal() { + return global.document ? global.document.getElementById(MODAL_ID) : null; } - // ==================== 数据导出功能 ==================== + function ensureModalDom() { + if (!global.document || !global.document.body) return null; + var existing = getModal(); + if (existing) return existing; - /** - * 导出练习记录 - */ - async exportPracticeRecords(options = {}) { - const { skipReady = false, format = 'json' } = options; + var modal = global.document.createElement('div'); + modal.id = MODAL_ID; + modal.className = 'theme-modal external-backup-modal shui-secondary-modal shui-secondary-modal--sm'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'external-backup-title'); - try { - console.log('[Storage] 开始导出练习记录'); + var content = global.document.createElement('div'); + content.className = 'theme-modal-content external-backup-modal__content shui-secondary-modal__content'; + var header = global.document.createElement('div'); + header.className = 'theme-modal-header external-backup-modal__header shui-secondary-modal__header'; + var title = global.document.createElement('h3'); + title.id = 'external-backup-title'; + title.textContent = '本地磁盘备份'; + var closeButton = global.document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'theme-modal-close'; + closeButton.setAttribute('aria-label', '关闭'); + closeButton.innerHTML = '×'; + header.appendChild(title); + header.appendChild(closeButton); - const records = await this.listPracticeRecordsCanonical({ skipReady }); + var body = global.document.createElement('div'); + body.className = 'theme-modal-body external-backup-modal__body shui-secondary-modal__body'; + var panel = global.document.createElement('div'); + panel.id = 'external-backup-panel'; + panel.className = 'external-backup-panel external-backup-panel--modal'; + var description = global.document.createElement('p'); + description.className = 'external-backup-panel__desc'; + description.textContent = '绑定本地文件夹后,IELTS Atlas 会写入完整的 v2 数据快照。磁盘文件不会因清理浏览器站点数据而删除;后台写入不会主动请求权限。'; + var statusCard = global.document.createElement('div'); + statusCard.className = 'external-backup-status-card'; + var statusLabel = global.document.createElement('div'); + statusLabel.className = 'external-backup-status-card__label'; + statusLabel.textContent = '当前状态'; + var statusText = global.document.createElement('div'); + statusText.id = 'external-backup-status'; + statusText.className = 'external-backup-panel__status'; + statusText.textContent = '状态加载中…'; + statusCard.appendChild(statusLabel); + statusCard.appendChild(statusText); - const exportData = { - type: 'practice_records', - version: this.version, - exportDate: new Date().toISOString(), - recordCount: records.length, - records: records - }; + var tips = global.document.createElement('ul'); + tips.className = 'external-backup-panel__tips'; + [ + '支持 Chrome / Edge 的安全上下文;其他环境继续使用手动导出', + '备份文件包含练习、设置、词汇、题库配置等可迁移数据', + '磁盘 JSON 为明文文件,请妥善保管' + ].forEach(function (text) { + var item = global.document.createElement('li'); + item.textContent = text; + tips.appendChild(item); + }); - console.log(`[Storage] 练习记录导出完成,共 ${records.length} 条`); + var actions = global.document.createElement('div'); + actions.className = 'external-backup-panel__actions'; + var bindButton = makeButton('external-backup-bind-btn', '📁 绑定备份文件夹'); + var writeButton = makeButton('external-backup-write-btn', '💾 立即写入备份'); + var restoreButton = makeButton('external-backup-restore-btn', '♻️ 从文件夹恢复'); + var unbindButton = makeButton('external-backup-unbind-btn', '🔓 解除绑定'); + unbindButton.classList.add('external-backup-btn--ghost'); + actions.appendChild(bindButton); + actions.appendChild(writeButton); + actions.appendChild(restoreButton); + actions.appendChild(unbindButton); + + panel.appendChild(description); + panel.appendChild(statusCard); + panel.appendChild(tips); + panel.appendChild(actions); + body.appendChild(panel); + content.appendChild(header); + content.appendChild(body); + modal.appendChild(content); + global.document.body.appendChild(modal); - if (format === 'json') { - return JSON.stringify(exportData, null, 2); + closeButton.addEventListener('click', closeModal); + modal.addEventListener('click', function (event) { + if (event.target === modal) closeModal(); + }); + bindButton.addEventListener('click', async function () { + try { + var bound = await bindDirectory({ writeNow: true }); + if (bound.existingBackupFound) { + notify('已绑定并检测到现有备份;为防止覆盖,请先从文件夹恢复', 'warning'); + } else if (bound.writeResult && !bound.writeResult.success) { + notify('文件夹已绑定,但首次写入失败', 'warning'); + } else { + notify('已绑定并写入:' + bound.directoryName, 'success'); + } + } catch (error) { + notify(error && error.name === 'AbortError' ? '已取消选择文件夹' : (error.message || '绑定失败'), error && error.name === 'AbortError' ? 'info' : 'error'); + } + refreshPanel(); + }); + writeButton.addEventListener('click', async function () { + var result = await writeToBoundDirectory({ interactive: true, force: true }); + if (result.success) notify(result.skipped ? '备份内容无变化' : '已写入 ' + result.filename, 'success'); + else if (result.reason === 'unbound') notify('请先绑定备份文件夹', 'warning'); + else if (result.reason === 'restore_required') notify('检测到现有备份,请先从文件夹恢复,避免覆盖', 'warning'); + else if (result.reason === 'permission_denied') notify('需要允许文件夹访问权限', 'warning'); + else notify('写入失败:' + (result.error && result.error.message || result.reason), 'error'); + }); + restoreButton.addEventListener('click', async function () { + try { + var restored = await restoreFromLatest(); + if (restored.success) { + notify('已从本地磁盘备份恢复', 'success'); + if (typeof global.syncPracticeRecords === 'function') { + Promise.resolve(global.syncPracticeRecords({ forceRender: true })).catch(function () {}); + } + } else if (restored.reason === 'cancelled') { + notify('已取消恢复', 'info'); + } + } catch (error) { + notify(error && error.message ? error.message : '恢复失败', 'error'); + } + }); + unbindButton.addEventListener('click', async function () { + var confirmed = true; + try { + confirmed = global.confirm('解除绑定后将停止自动写入;磁盘上的 JSON 文件不会删除。确定?'); + } catch (_) { /* ignore */ } + if (!confirmed) return; + try { + await unbindDirectory(); + notify('已解除本地备份文件夹绑定', 'info'); + } catch (error) { + notify(error && error.message ? error.message : '解除绑定失败', 'error'); } + }); + return modal; + } - return exportData; - } catch (error) { - console.error('[Storage] 导出练习记录失败:', error); - return null; + function refreshPanel() { + if (!global.document) return; + var status = getStatus(); + var statusElement = global.document.getElementById('external-backup-status'); + if (statusElement) { + statusElement.textContent = formatStatusText(status); + statusElement.dataset.state = !status.supported ? 'unsupported' + : !status.bound ? 'unbound' + : !status.permissionGranted ? 'need-auth' + : status.dirty ? 'stale' : 'ok'; + } + var entry = global.document.getElementById(ENTRY_ID); + if (entry) { + entry.textContent = !status.bound ? '📁 本地磁盘备份' + : !status.permissionGranted ? '📁 本地备份 · 需授权' + : status.dirty ? '📁 本地备份 · 待更新' : '📁 本地备份 · 已就绪'; + entry.dataset.state = statusElement && statusElement.dataset.state || 'unbound'; } + var bindButton = global.document.getElementById('external-backup-bind-btn'); + var writeButton = global.document.getElementById('external-backup-write-btn'); + var restoreButton = global.document.getElementById('external-backup-restore-btn'); + var unbindButton = global.document.getElementById('external-backup-unbind-btn'); + if (bindButton) bindButton.disabled = !status.supported || status.writing; + if (writeButton) writeButton.disabled = !status.bound || status.writing; + if (restoreButton) restoreButton.disabled = !status.bound || status.writing; + if (unbindButton) unbindButton.disabled = !status.bound || status.writing; } - /** - * 导出词表数据 - */ - async exportVocabLists(options = {}) { - const { skipReady = false, format = 'json', listIds = null } = options; + function openModal() { + var modal = ensureModalDom(); + if (modal) modal.classList.add('show'); + ensureReady().then(async function () { + if (state.directoryHandle) state.permission = await queryPermission(state.directoryHandle, 'readwrite'); + refreshPanel(); + }).catch(function (error) { + if (global.console && console.warn) console.warn('[ExternalBackup v2] initialization failed:', error); + refreshPanel(); + }); + } - try { - console.log('[Storage] 开始导出词表数据'); - - const vocabLists = []; - const targetListIds = listIds || [ - 'spelling-errors-p1', - 'spelling-errors-p4', - 'spelling-errors-master', - 'custom' - ]; + function closeModal() { + var modal = getModal(); + if (modal) modal.classList.remove('show'); + } - for (const listId of targetListIds) { - const list = await this.loadVocabList(listId, { skipReady }); - if (list && list.words.length > 0) { - vocabLists.push(list); + async function ensureReady() { + if (state.ready) return true; + if (state.readyPromise) return state.readyPromise; + state.readyPromise = (async function () { + if (global.AppData && global.AppData.ready) await global.AppData.ready; + if (state.suspended) { + state.ready = true; + return false; + } + if (supportsFileSystemAccess()) { + try { + var stored = await Promise.all([ + readStoredValue(HANDLE_KEY), + readStoredValue(META_KEY) + ]); + state.directoryHandle = stored[0] || null; + state.meta = cloneMeta(stored[1]); + if (state.directoryHandle) { + state.permission = await queryPermission(state.directoryHandle, 'readwrite'); + if (!state.meta.directoryName) { + state.meta.directoryName = state.directoryHandle.name || 'backup'; + } + } + } catch (error) { + if (global.console && console.warn) console.warn('[ExternalBackup v2] binding load failed:', error); } } - - const exportData = { - type: 'vocabulary_lists', - version: this.version, - exportDate: new Date().toISOString(), - listCount: vocabLists.length, - totalWords: vocabLists.reduce((sum, list) => sum + list.words.length, 0), - lists: vocabLists - }; - - console.log(`[Storage] 词表导出完成,共 ${vocabLists.length} 个词表,${exportData.totalWords} 个单词`); - - if (format === 'json') { - return JSON.stringify(exportData, null, 2); + var backups = global.AppData && global.AppData.backups; + if (backups && typeof backups.onDataCommitted === 'function' && !state.unsubscribeCommitted) { + state.unsubscribeCommitted = backups.onDataCommitted(markDirty); + } + if (state.directoryHandle && backups && typeof backups.export === 'function') { + try { + var currentSnapshot = await backups.export(); + if (!currentSnapshot || currentSnapshot.checksum !== state.meta.lastChecksum) { + state.dirty = true; + state.dirtyGeneration += 1; + } + } catch (error) { + if (global.console && console.warn) console.warn('[ExternalBackup v2] freshness check failed:', error); + } } + state.ready = true; + if (state.dirty && state.permission === 'granted') scheduleSilentFlush(); + refreshPanel(); + return true; + })(); + return state.readyPromise; + } - return exportData; - } catch (error) { - console.error('[Storage] 导出词表数据失败:', error); - return null; + async function init() { + await ensureReady(); + if (state.suspended) return false; + ensureModalDom(); + refreshPanel(); + if (global.document && !state.initialized) { + state.visibilityHandler = function () { + if (state.suspended) return; + if (global.document.visibilityState === 'hidden') { + flushSilentlyIfPermitted().catch(function () {}); + } else if (state.directoryHandle) { + queryPermission(state.directoryHandle, 'readwrite').then(function (permission) { + state.permission = permission; + if (permission === 'granted' && state.dirty) scheduleSilentFlush(); + refreshPanel(); + }); + } + }; + global.document.addEventListener('visibilitychange', state.visibilityHandler); } + state.initialized = true; + return true; } - /** - * 导出单个词表 - */ - async exportSingleVocabList(listId, options = {}) { - const { skipReady = false, format = 'json' } = options; - - try { - console.log(`[Storage] 开始导出词表: ${listId}`); - - const list = await this.loadVocabList(listId, { skipReady }); + global.ExternalBackupService = Object.freeze({ + __v2: true, + LATEST_FILENAME: LATEST_FILENAME, + supportsFileSystemAccess: supportsFileSystemAccess, + ensureReady: ensureReady, + init: init, + openModal: openModal, + closeModal: closeModal, + bindDirectory: bindDirectory, + unbindDirectory: unbindDirectory, + prepareForFullReset: prepareForFullReset, + writeNow: function (options) { + return writeToBoundDirectory(Object.assign({ interactive: true, force: true }, options || {})); + }, + restoreFromLatest: restoreFromLatest, + restorePayload: restorePayload, + getStatus: getStatus, + markDirty: markDirty, + flushSilentlyIfPermitted: flushSilentlyIfPermitted, + refreshPanel: refreshPanel, + requestPersistentStorage: requestPersistentStorage + }); - if (!list) { - console.warn(`[Storage] 词表不存在: ${listId}`); - return null; - } + function boot() { + init().catch(function (error) { + if (global.console && console.warn) console.warn('[ExternalBackup v2] boot failed:', error); + }); + } - const exportData = { - type: 'vocabulary_list', - version: this.version, - exportDate: new Date().toISOString(), - list: list - }; + if (global.document && global.document.readyState === 'loading') { + global.document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(typeof window !== 'undefined' ? window : globalThis); - console.log(`[Storage] 词表导出完成: ${listId}, ${list.words.length} 个单词`); - if (format === 'json') { - return JSON.stringify(exportData, null, 2); - } +/* ===== js/core/siteDataReset.js ===== */ +/** + * Destructive browser-site reset. + * + * This path deliberately bypasses AppData domain mutations. A reset must not + * append operation journals, rebuild projectors, or flush an empty snapshot to + * the bound external backup folder. + */ +(function initSiteDataReset(global) { + 'use strict'; - return exportData; - } catch (error) { - console.error('[Storage] 导出词表失败:', error); - return null; + if (global.SiteDataReset && global.SiteDataReset.__v2 === true) { + if (typeof global.clearCache !== 'function') { + global.clearCache = global.SiteDataReset.request; } + return; } + var DATABASE_NAMES = Object.freeze([ + 'IELTSAtlasDataV2', + 'ExamSystemDB', + 'IELTSAtlasExternalBackupV2' + ]); /** - * 导出完整数据(包括练习记录和词表) + * How long a `blocked` deletion is allowed to keep waiting before it is + * reported as a failure. + * + * A cooperative peer (data kernel connections install `onversionchange` and + * close immediately) releases the database within a tick, while a peer that + * is in the middle of a long write can legitimately hold it for a few + * seconds. Waiting far beyond that only makes an unrecoverable block look + * like a frozen UI, and every database is deleted in parallel, so this is + * the worst case for the whole reset rather than a per-database cost. */ - async exportCompleteData(options = {}) { - const { skipReady = false, format = 'json' } = options; - - try { - console.log('[Storage] 开始导出完整数据'); - - // 导出所有数据 - const allData = await this.exportData({ skipReady }); - - // 导出练习记录 - const practiceRecords = await this.exportPracticeRecords({ - skipReady, - format: 'object' - }); - - // 导出词表 - const vocabLists = await this.exportVocabLists({ - skipReady, - format: 'object' - }); - - const exportData = { - type: 'complete_export', - version: this.version, - exportDate: new Date().toISOString(), - summary: { - totalRecords: allData?.storageInfo?.totalRecords || 0, - practiceRecords: practiceRecords?.recordCount || 0, - vocabLists: vocabLists?.listCount || 0, - totalWords: vocabLists?.totalWords || 0 - }, - data: { - all: allData, - practiceRecords: practiceRecords, - vocabLists: vocabLists - } - }; - - console.log('[Storage] 完整数据导出完成'); - - if (format === 'json') { - return JSON.stringify(exportData, null, 2); - } - - return exportData; - } catch (error) { - console.error('[Storage] 导出完整数据失败:', error); - return null; - } - } - + var BLOCKED_DELETE_TIMEOUT_MS = 8000; + var EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS = 8000; /** - * 下载导出数据为文件 + * `IDBFactory.deleteDatabase()` has no `abort()`. Once the blocked timeout + * wins, the browser keeps the request armed and will drop the database the + * moment the peer connection closes — possibly minutes later, possibly after + * this page reloaded and started using a freshly created database. + * + * `pendingDeletions` is that un-cancellable tail: a database name stays here + * from the moment we give up waiting until the browser actually reports the + * request as done. While a name is listed the reset is "armed but not + * finished", which is a materially different state from both "succeeded" and + * "failed" and must be surfaced as such. */ - downloadExportData(data, filename = null) { - try { - if (!data) { - console.error('[Storage] 无数据可导出'); - return false; - } - - // 确保数据是字符串格式 - const jsonString = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - - // 创建 Blob - const blob = new Blob([jsonString], { type: 'application/json' }); - - // 生成文件名 - const defaultFilename = `ielts-practice-export-${new Date().toISOString().split('T')[0]}.json`; - const finalFilename = filename || defaultFilename; - - // 创建下载链接 - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = finalFilename; - - // 触发下载 - document.body.appendChild(link); - link.click(); - - // 清理 - document.body.removeChild(link); - URL.revokeObjectURL(url); - - console.log(`[Storage] 数据已下载: ${finalFilename}`); - return true; - } catch (error) { - console.error('[Storage] 下载导出数据失败:', error); - return false; - } - } - + var pendingDeletions = new Map(); + var pendingDeletionSequence = 0; /** - * 导出并下载练习记录 + * Cross-refresh recovery marker. + * + * A reloaded page cannot observe the previous page's `IDBRequest` — that + * object died with the old realm — so the in-memory registry above is lost on + * every reload. The marker carries the *fact* that a reset is still armed + * across the reload so the new page can tell the user the truth instead of + * looking pristine. + * + * It never re-arms a delete by itself. A later realm must obtain explicit + * recovery confirmation before it may queue a replacement deletion. */ - async exportAndDownloadPracticeRecords(filename = null) { - try { - const data = await this.exportPracticeRecords({ format: 'json' }); - if (data) { - const defaultFilename = `practice-records-${new Date().toISOString().split('T')[0]}.json`; - return this.downloadExportData(data, filename || defaultFilename); - } - return false; - } catch (error) { - console.error('[Storage] 导出并下载练习记录失败:', error); - return false; - } - } - + var PENDING_DELETION_MARKER_KEY = 'ielts_atlas:v2:site-reset:pending-deletions'; + var WINDOW_NAME_MARKER_PREFIX = '__IELTS_ATLAS_SITE_RESET__:'; /** - * 导出并下载词表数据 + * The marker is written *after* `clearWebStorage()` (it would be wiped + * otherwise), which means it is the one key that survives a "clear + * everything" run. Age is used to strengthen the recovery warning, not to + * guess that the underlying request completed. Explicit recovery confirmation + * is the bounded escape hatch for a marker whose old realm is gone forever. */ - async exportAndDownloadVocabLists(filename = null) { + var PENDING_DELETION_MARKER_TTL_MS = 600000; + var adoptedPendingDatabases = []; + var adoptedPendingMarkerState = null; + var resetPromise = null; + + function nowMs() { try { - const data = await this.exportVocabLists({ format: 'json' }); - if (data) { - const defaultFilename = `vocab-lists-${new Date().toISOString().split('T')[0]}.json`; - return this.downloadExportData(data, filename || defaultFilename); - } - return false; - } catch (error) { - console.error('[Storage] 导出并下载词表数据失败:', error); - return false; - } + if (typeof Date === 'function' && typeof Date.now === 'function') return Date.now(); + } catch (_) { /* exotic host */ } + return 0; } - /** - * 导出并下载完整数据 - */ - async exportAndDownloadCompleteData(filename = null) { - try { - const data = await this.exportCompleteData({ format: 'json' }); - if (data) { - const defaultFilename = `complete-data-${new Date().toISOString().split('T')[0]}.json`; - return this.downloadExportData(data, filename || defaultFilename); - } - return false; - } catch (error) { - console.error('[Storage] 导出并下载完整数据失败:', error); - return false; + function notify(message, type) { + if (typeof global.showMessage === 'function') { + global.showMessage(message, type || 'info'); + } else if (global.console && typeof global.console.log === 'function') { + global.console.log('[SiteDataReset] ' + message); } } - /** - * 导入词表数据 - */ - async importVocabLists(importData, options = {}) { - const { skipReady = false, merge = true } = options; - + // Timers are looked up defensively: this module is also loaded inside test + // realms and worker-like hosts that do not expose the full window surface. + function hostSetTimeout(callback, delay) { try { - console.log('[Storage] 开始导入词表数据'); - - if (!importData || !importData.lists) { - console.error('[Storage] 导入数据格式无效'); - return false; + if (global && typeof global.setTimeout === 'function') { + return { id: global.setTimeout(callback, delay), host: global }; } - - let successCount = 0; - let failCount = 0; - - for (const list of importData.lists) { - try { - if (merge) { - // 合并模式:与现有数据合并 - const success = await this.syncVocabList(list.id, list, { skipReady }); - if (success) { - successCount++; - } else { - failCount++; - } - } else { - // 覆盖模式:直接保存 - const success = await this.saveVocabList(list, { skipReady }); - if (success) { - successCount++; - } else { - failCount++; - } - } - } catch (error) { - console.error(`[Storage] 导入词表失败: ${list.id}`, error); - failCount++; - } - } - - console.log(`[Storage] 词表导入完成: ${successCount} 成功, ${failCount} 失败`); - return { successCount, failCount }; - } catch (error) { - console.error('[Storage] 导入词表数据失败:', error); - return false; + } catch (_) { /* fall through to the ambient timer */ } + if (typeof setTimeout === 'function') { + return { id: setTimeout(callback, delay), host: null }; } + return null; } -} -const STORAGE_SYNC_IGNORED_KEYS = new Set([ - 'namespace_test', - 'namespace_test_practice', - 'namespace_test_enhancer' -]); - -StorageManager.prototype.dispatchStorageSync = function(key) { - try { - const normalizedKey = typeof key === 'string' ? key.replace(this.prefix, '') : key; - if (normalizedKey && STORAGE_SYNC_IGNORED_KEYS.has(normalizedKey)) { - return; + function hostClearTimeout(handle) { + if (!handle) return null; + try { + if (handle.host && typeof handle.host.clearTimeout === 'function') { + handle.host.clearTimeout(handle.id); + return null; + } + } catch (_) { + return null; } - } catch (_) { - // ignore errors resolving key + if (typeof clearTimeout === 'function') clearTimeout(handle.id); + return null; } - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } })); -}; -// 创建全局存储实例 -class PreferenceStore { - constructor(prefix = 'exam_system_') { - this.prefix = prefix; - this.ready = Promise.resolve(); + function createBlockedError(name) { + var error = new Error( + '数据库被其他 IELTS Atlas 标签页占用,未能删除:' + name + + '(等待 ' + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒后放弃)。' + ); + error.code = 'DELETE_DATABASE_BLOCKED'; + error.blocked = true; + error.database = name; + error.timeoutMs = BLOCKED_DELETE_TIMEOUT_MS; + return error; } - setNamespace(namespace) { - if (typeof namespace === 'string' && namespace.trim()) { - this.prefix = namespace.trim() + '_'; - } + function createQuiesceTimeoutError() { + var error = new Error( + '外部备份停止写入超时(等待 ' + + Math.round(EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS / 1000) + ' 秒)。' + ); + error.code = 'EXTERNAL_BACKUP_QUIESCE_TIMEOUT'; + error.timeoutMs = EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS; + return error; } - getScopedKey(key) { - return key.startsWith(this.prefix) ? key : this.prefix + key; + function readStorage(name) { + try { + var storage = global[name]; + if (storage && typeof storage.getItem === 'function') return storage; + } catch (_) { /* storage disabled by policy or a sandboxed frame */ } + return null; } - getStorageArea(session = false) { - return session ? window.sessionStorage : window.localStorage; + function markerStorages() { + return [readStorage('localStorage'), readStorage('sessionStorage')].filter(function (storage, index, all) { + return !!storage && all.indexOf(storage) === index; + }); } - serialize(value) { - return JSON.stringify({ data: value, timestamp: Date.now() }); + function readWindowNameMarker() { + var value = ''; + try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return null; } + var parts = value.split('\n'); + for (var index = parts.length - 1; index >= 0; index -= 1) { + if (parts[index].indexOf(WINDOW_NAME_MARKER_PREFIX) === 0) { + return parts[index].slice(WINDOW_NAME_MARKER_PREFIX.length); + } + } + return null; } - deserialize(rawValue, defaultValue = null) { - if (!rawValue) { - return defaultValue; - } + function replaceWindowNameMarker(raw) { + var value = ''; + try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return false; } + var retained = value.split('\n').filter(function (part) { + return part.indexOf(WINDOW_NAME_MARKER_PREFIX) !== 0; + }); + if (retained.length === 1 && retained[0] === '') retained = []; + if (raw) retained.push(WINDOW_NAME_MARKER_PREFIX + raw); try { - const parsed = JSON.parse(rawValue); - return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data') - ? parsed.data - : defaultValue; + global.name = retained.join('\n'); + return raw ? readWindowNameMarker() === raw : readWindowNameMarker() === null; } catch (_) { - return defaultValue; + return false; } } - async get(key, defaultValue = null, options = {}) { - const storage = this.getStorageArea(options.session === true); - return this.deserialize(storage.getItem(this.getScopedKey(key)), defaultValue); - } - - async set(key, value, options = {}) { - const storage = this.getStorageArea(options.session === true); - storage.setItem(this.getScopedKey(key), this.serialize(value)); - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } })); - return true; - } - - async remove(key, options = {}) { - const storage = this.getStorageArea(options.session === true); - storage.removeItem(this.getScopedKey(key)); - window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } })); - return true; + /** + * Persist the recovery marker. Called only from the tail of `perform`, after + * `clearWebStorage()`, so the value is not immediately erased by the very + * reset that produced it. + */ + function writePendingDeletionMarker(names) { + if (!names || !names.length) { + clearPendingDeletionMarker(); + return true; + } + var value = JSON.stringify({ state: 'pending', databases: names.slice(), at: nowMs() }); + var persisted = false; + markerStorages().forEach(function (storage) { + if (typeof storage.setItem !== 'function') return; + try { + storage.setItem(PENDING_DELETION_MARKER_KEY, value); + persisted = storage.getItem(PENDING_DELETION_MARKER_KEY) === value || persisted; + } catch (_) { /* try the other storage */ } + }); + persisted = replaceWindowNameMarker(value) || persisted; + return persisted; } - async clear(options = {}) { - const storage = this.getStorageArea(options.session === true); - Object.keys(storage) - .filter((key) => key.startsWith(this.prefix)) - .forEach((key) => storage.removeItem(key)); - return true; - } -} - -class StorageKeyRegistry { - constructor() { - this.preferenceKeys = new Set([ - 'theme_settings', - 'current_theme', - 'keyboard_shortcuts_enabled', - 'sound_effects_enabled', - 'auto_save_enabled', - 'notifications_enabled', - 'theme', - 'bloom-theme-mode', - 'blue-theme-mode', - 'browse_state', - 'hasSeenGplLicense', - 'preferred_theme_portal' - ]); - this.sessionKeys = new Set([ - 'preferred_theme_skip_session' - ]); + function clearPendingDeletionMarker() { + markerStorages().forEach(function (storage) { + if (typeof storage.removeItem !== 'function') return; + try { + storage.removeItem(PENDING_DELETION_MARKER_KEY); + } catch (_) { /* best-effort */ } + }); + replaceWindowNameMarker(null); } - resolve(key) { - if (this.sessionKeys.has(key)) { - return { key, storageClass: 'session' }; - } - if (this.preferenceKeys.has(key)) { - return { key, storageClass: 'preference' }; + /** + * Read a marker left by a previous page load. + * + * Expired or malformed evidence cannot prove that the old request completed. + * Keep the page in a recoverable confirmation-required state instead of + * silently turning uncertainty into "safe". + */ + function readPendingDeletionMarker() { + var sawMarker = false; + var invalidMarker = false; + var validCandidate = null; + var rawMarkers = []; + markerStorages().forEach(function (storage) { + try { rawMarkers.push(storage.getItem(PENDING_DELETION_MARKER_KEY)); } catch (_) { /* unreadable */ } + }); + rawMarkers.push(readWindowNameMarker()); + rawMarkers.forEach(function (raw) { + if (!raw) return; + sawMarker = true; + var parsed = null; + try { parsed = JSON.parse(raw); } catch (_) { invalidMarker = true; return; } + var names = parsed && parsed.databases; + var state = parsed && parsed.state; + if (state && state !== 'pending' && state !== 'unknown') { + invalidMarker = true; + return; + } + if (!names || typeof names.length !== 'number' || !names.length) { + invalidMarker = true; + return; + } + var adopted = []; + for (var index = 0; index < names.length; index += 1) { + if (DATABASE_NAMES.indexOf(names[index]) !== -1 && adopted.indexOf(names[index]) === -1) { + adopted.push(names[index]); + } + } + if (!adopted.length) { invalidMarker = true; return; } + var at = Number(parsed.at); + var age = nowMs() - (isFinite(at) ? at : 0); + validCandidate = { + databases: adopted, + state: 'unknown', + expired: !isFinite(at) || age < 0 || age > PENDING_DELETION_MARKER_TTL_MS + }; + }); + if (validCandidate) return validCandidate; + if (sawMarker || invalidMarker) { + return { databases: DATABASE_NAMES.slice(), state: 'unknown', corrupt: true }; } - return { key, storageClass: 'persistent' }; - } -} - -class StorageFacade { - constructor(options = {}) { - this.persistentStore = options.persistentStore; - this.preferenceStore = options.preferenceStore; - this.keyRegistry = options.keyRegistry; - this.ready = this.persistentStore ? this.persistentStore.ready : Promise.resolve(); + return { databases: [], state: 'retired' }; } - setNamespace(namespace) { - if (this.persistentStore && typeof this.persistentStore.setNamespace === 'function') { - this.persistentStore.setNamespace(namespace); + /** + * Register a deletion request we stopped waiting for, and keep watching it. + * + * The handlers installed here are intentionally *not* the ones `settle()` + * detached: those could still resolve the caller's promise and rewrite an + * outcome that has already been reported. These are pure observers — their + * only job is to notice that the un-cancellable request finally ran, so the + * pending state can be retired truthfully instead of by timeout. + */ + function trackPendingDeletion(name, request) { + pendingDeletionSequence += 1; + var token = pendingDeletionSequence; + pendingDeletions.set(name, { token: token, at: nowMs(), request: request }); + + function retire() { + var entry = pendingDeletions.get(name); + // A newer reset attempt may have replaced this entry; only the owner + // of the current token may retire it. + if (!entry || entry.token !== token) return; + pendingDeletions.delete(name); + var remaining = listLivePendingDeletions(); + if (remaining.length) { + writePendingDeletionMarker(remaining); + } else { + clearPendingDeletionMarker(); + } } - if (this.preferenceStore && typeof this.preferenceStore.setNamespace === 'function') { - this.preferenceStore.setNamespace(namespace); + + try { + request.onsuccess = function () { retire(); }; + request.onerror = function () { retire(); }; + // A repeated `onblocked` means the peer is still holding on. Nothing + // to retire yet, but swallow it so it cannot reach a stale handler. + request.onblocked = function () { }; + } catch (_) { + // Read-only handlers are rare, but guessing completion would be + // unsafe. The entry therefore remains restricted until this realm is + // torn down and the cross-refresh recovery flow takes over. } + return token; } - resolveStore(key) { - const entry = this.keyRegistry.resolve(key); - if (entry.storageClass === 'preference') { - return { entry, store: this.preferenceStore, options: { session: false } }; - } - if (entry.storageClass === 'session') { - return { entry, store: this.preferenceStore, options: { session: true } }; - } - return { entry, store: this.persistentStore, options: {} }; + /** + * Live pending deletions: requests this realm issued and can still observe. + * + * Only these gate a new reset. An entry leaves this list the moment the + * browser reports the deletion done, so the common "close the other tab and + * retry" path unblocks immediately rather than waiting out a timer. + */ + function listLivePendingDeletions() { + var names = []; + pendingDeletions.forEach(function (_entry, name) { + if (names.indexOf(name) === -1) names.push(name); + }); + return names; } - async get(key, defaultValue = null, options = {}) { - const target = this.resolveStore(key); - return await target.store.get(key, defaultValue, Object.assign({}, target.options, options)); + /** Live plus adopted names — everything worth telling the user about. */ + function listPendingDeletions() { + var names = listLivePendingDeletions(); + for (var index = 0; index < adoptedPendingDatabases.length; index += 1) { + if (names.indexOf(adoptedPendingDatabases[index]) === -1) names.push(adoptedPendingDatabases[index]); + } + return names; } - async set(key, value, options = {}) { - const target = this.resolveStore(key); - return await target.store.set(key, value, Object.assign({}, target.options, options)); + function currentDeletionState() { + if (listLivePendingDeletions().length) return 'pending'; + if (adoptedPendingDatabases.length) return 'unknown'; + return 'retired'; } - async remove(key, options = {}) { - const target = this.resolveStore(key); - return await target.store.remove(key, Object.assign({}, target.options, options)); + /** + * The reason a caller must not start a new reset right now, or null. + * + * Live requests only retire on their real terminal event. Cross-refresh + * evidence can be recovered from, but only after explicit confirmation; this + * avoids both an automatic false-safe state and a permanent marker lockout. + */ + function pendingDeletionBlock(options) { + var live = listLivePendingDeletions(); + var adopted = adoptedPendingDatabases.slice(); + if (!live.length && !adopted.length) return null; + var recoveryRequired = !live.length && adopted.length > 0 + && !(options && options.recoveryConfirmed === true); + if (!live.length && !recoveryRequired) return null; + return { + success: false, + reason: recoveryRequired ? 'recovery_confirmation_required' : 'deletion_pending', + deletionPending: true, + pendingDatabases: listPendingDeletions(), + retryable: true, + recoveryConfirmationRequired: recoveryRequired, + deletionState: currentDeletionState(), + markerExpired: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.expired), + markerCorrupt: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.corrupt), + terminal: false, + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; } - async clear(options = {}) { - if (this.persistentStore && typeof this.persistentStore.clear === 'function') { - await this.persistentStore.clear(options); - } - if (this.preferenceStore && typeof this.preferenceStore.clear === 'function') { - await this.preferenceStore.clear({ session: false }); - await this.preferenceStore.clear({ session: true }); - } - return true; + function pendingDeletionMessage(names) { + return '上一次清理仍在等待其他 IELTS Atlas 标签页关闭:' + names.join('、') + + '。浏览器无法取消这个删除请求,它会在其他标签页关闭后自动执行;' + + '在那之前请不要录入新数据,否则可能被这次迟到的删除一并清掉。'; } - async getStorageInfo(options = {}) { - const persistentInfo = this.persistentStore && typeof this.persistentStore.getStorageInfo === 'function' - ? await this.persistentStore.getStorageInfo(options) - : null; - return Object.assign({}, persistentInfo || {}, { - facade: 'storage-facade', - volatile: Boolean(this.persistentStore && this.persistentStore.volatileMode) + function settleWithTimeout(value, timeoutMs, createTimeoutError) { + return new Promise(function (resolve, reject) { + var settled = false; + var timer = hostSetTimeout(function () { + if (settled) return; + settled = true; + reject(createTimeoutError()); + }, timeoutMs); + if (!timer) { + reject(createTimeoutError()); + return; + } + Promise.resolve(value).then(function (result) { + if (settled) return; + settled = true; + hostClearTimeout(timer); + resolve(result); + }, function (error) { + if (settled) return; + settled = true; + hostClearTimeout(timer); + reject(error); + }); }); } -} - -const storageManager = new StorageManager(); -const preferenceStore = new PreferenceStore(storageManager.prefix); -const storageKeyRegistry = new StorageKeyRegistry(); -const storageFacade = new StorageFacade({ - persistentStore: storageManager, - preferenceStore, - keyRegistry: storageKeyRegistry -}); - -window.persistentStore = storageManager; -window.preferenceStore = preferenceStore; -window.storageKeyRegistry = storageKeyRegistry; -window.storage = storageFacade; -Object.defineProperty(window, '__installStorageInternalAccess', { - value(install) { - if (typeof install !== 'function') { - throw new Error('__installStorageInternalAccess requires an installer function'); - } - const result = install(createInternalAccessOptions, hasInternalAccessOptions); - if (result !== false) { + + function deleteDatabaseStrict(name) { + return new Promise(function (resolve, reject) { + var indexedDb; try { - delete window.__installStorageInternalAccess; + indexedDb = global.indexedDB || null; } catch (_) { - window.__installStorageInternalAccess = undefined; + indexedDb = null; + } + if (!indexedDb || typeof indexedDb.deleteDatabase !== 'function') { + resolve({ name: name, skipped: true }); + return; } - } - return result; - }, - enumerable: false, - configurable: true, - writable: false -}); - -// 启动存储监控和数据同步 -storageManager.ready - .then(() => { - storageManager.startStorageMonitoring(); - storageManager.setupBeforeUnloadHandler(); - }) - .catch(error => { - console.error('[Storage] 存储初始化失败,监控未启动:', error); - }); -})(typeof window !== 'undefined' ? window : globalThis); + var request; + try { + request = indexedDb.deleteDatabase(name); + } catch (error) { + reject(error); + return; + } -/* ===== js/core/storageProviderRegistry.js ===== */ -(function(window) { - const listeners = new Set(); - let providers = null; + var settled = false; + var blockedTimer = null; + + function settle(complete, payload) { + if (settled) return; + settled = true; + blockedTimer = hostClearTimeout(blockedTimer); + // An IndexedDB deleteDatabase request cannot be aborted. When the + // blocked timeout wins, the browser keeps the request pending and + // will still drop the database once the other tab releases its + // connection. Detaching the handlers here stops a late event from + // rewriting an outcome the caller already acted on; the request is + // then handed to `trackPendingDeletion`, whose observer handlers do + // nothing but retire the pending state when the delete really runs. + try { + request.onsuccess = null; + request.onerror = null; + request.onblocked = null; + } catch (_) { /* exotic hosts may expose read-only handlers */ } + var abandoned = !!(payload && payload.code === 'DELETE_DATABASE_BLOCKED'); + if (abandoned) trackPendingDeletion(name, request); + complete(payload); + } - function normalizeProviders(input) { - if (!input || typeof input !== 'object') { - return null; - } - const normalized = { - storageManager: input.storageManager || window.storage || null, - persistentStore: input.persistentStore || window.persistentStore || null, - preferenceStore: input.preferenceStore || window.preferenceStore || null, - repositories: input.repositories || null, - simpleStorageWrapper: input.simpleStorageWrapper || null - }; - if (!normalized.repositories) { - return null; - } - return normalized; + request.onsuccess = function () { + settle(resolve, { name: name, deleted: true }); + }; + request.onerror = function () { + settle(reject, request.error || new Error('删除数据库失败:' + name)); + }; + request.onblocked = function () { + if (settled || blockedTimer) return; + notify( + '清理被其他 IELTS Atlas 标签页阻塞,请立即关闭其他标签页;' + + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒内未释放将中止本次清理。', + 'warning' + ); + blockedTimer = hostSetTimeout(function () { + settle(reject, createBlockedError(name)); + }, BLOCKED_DELETE_TIMEOUT_MS); + if (!blockedTimer) { + // No timer API at all: fail fast rather than wait forever. + settle(reject, createBlockedError(name)); + } + }; + }); } - function notifyListeners(payload) { - listeners.forEach((listener) => { + function clearWebStorage() { + var failures = []; + ['localStorage', 'sessionStorage'].forEach(function (name) { + var storage; + try { + storage = global[name]; + } catch (error) { + failures.push({ storage: name, error: error }); + return; + } + if (!storage || typeof storage.clear !== 'function') return; try { - listener(payload); + storage.clear(); } catch (error) { - console.error('[StorageProviderRegistry] listener failed:', error); + failures.push({ storage: name, error: error }); } }); + return failures; } - function registerStorageProviders(input) { - const normalized = normalizeProviders(input); - if (!normalized) { - throw new Error('registerStorageProviders requires repositories'); - } - providers = normalized; - - if (!window.dataRepositories) { - window.dataRepositories = normalized.repositories; - } - if (!window.storage && normalized.storageManager) { - window.storage = normalized.storageManager; - } - if (!window.persistentStore && normalized.persistentStore) { - window.persistentStore = normalized.persistentStore; - } - if (!window.preferenceStore && normalized.preferenceStore) { - window.preferenceStore = normalized.preferenceStore; - } - if (normalized.simpleStorageWrapper && !window.simpleStorageWrapper) { - window.simpleStorageWrapper = normalized.simpleStorageWrapper; + function reloadTerminal(options) { + if (options && options.reload === false) return false; + if (global.location && typeof global.location.reload === 'function') { + global.location.reload(); + return true; } - - notifyListeners(Object.assign({}, providers)); - return providers; + return false; } - function onProvidersReady(callback) { - if (typeof callback !== 'function') { - return () => {}; - } - listeners.add(callback); - if (providers) { + async function perform(options) { + var opts = options || {}; + if (resetPromise) return resetPromise; + // Refuse to queue a second un-cancellable deletion behind one that is + // still armed. Checked before the singleton is installed so the refusal + // is never cached as "the" result of a reset. + var blockedByPending = pendingDeletionBlock(opts); + if (blockedByPending) { + notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); + return blockedByPending; + } + resetPromise = (async function () { + var externalBackup = global.ExternalBackupService; + var errors = []; try { - callback(Object.assign({}, providers)); + if (externalBackup && typeof externalBackup.prepareForFullReset === 'function') { + await settleWithTimeout( + externalBackup.prepareForFullReset(), + EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, + createQuiesceTimeoutError + ); + } else if (externalBackup && typeof externalBackup.unbindDirectory === 'function') { + await settleWithTimeout( + externalBackup.unbindDirectory(), + EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, + createQuiesceTimeoutError + ); + } } catch (error) { - console.error('[StorageProviderRegistry] immediate callback failed:', error); + errors.push({ stage: 'external-backup-quiesce', error: error }); + } + + var deletionResults = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabaseStrict)); + var blockedDatabases = []; + deletionResults.forEach(function (result, index) { + if (result.status !== 'rejected') return; + var reason = result.reason; + var isBlocked = !!(reason && reason.code === 'DELETE_DATABASE_BLOCKED'); + if (isBlocked) blockedDatabases.push(DATABASE_NAMES[index]); + errors.push({ + stage: isBlocked ? 'delete-database-blocked' : 'delete-database', + database: DATABASE_NAMES[index], + blocked: isBlocked, + error: reason + }); + }); + clearWebStorage().forEach(function (failure) { + errors.push({ + stage: 'clear-web-storage', + storage: failure.storage, + error: failure.error + }); + }); + + // Written after clearWebStorage() on purpose: the reset wipes every + // key, so a marker persisted any earlier would erase itself. This is + // the one key that legitimately survives a full reset, which is why + // it carries its own TTL. + // + // Adopted names are dropped unconditionally here. This run issued a + // fresh deleteDatabase() for every name, and the connection queue is + // FIFO per database: whatever a previous realm queued was necessarily + // processed ahead of the request we just awaited, so it is no longer + // outstanding regardless of how this run ended. + adoptedPendingDatabases = []; + adoptedPendingMarkerState = null; + var stillPending = listLivePendingDeletions(); + var markerPersisted = true; + if (stillPending.length) { + markerPersisted = writePendingDeletionMarker(stillPending); + if (!markerPersisted) { + errors.push({ + stage: 'pending-deletion-marker', + error: new Error('无法持久化仍在等待的数据库删除状态。') + }); + } + } else { + clearPendingDeletionMarker(); + } + + if (errors.length) { + if (blockedDatabases.length) { + notify( + '清理未完成:' + blockedDatabases.join('、') + + ' 仍被其他 IELTS Atlas 标签页占用。浏览器无法取消该删除请求,' + + '它会在其他标签页关闭后自动执行。请关闭全部其他标签页(含练习/听力弹窗)后,' + + '等待当前页面确认删除完成后再重试,在此之前不要继续录入新数据。', + 'error' + ); + } else { + notify('本地数据仅部分清除,页面将刷新;请刷新后再次执行清理。', 'error'); + } + // Keep this realm alive while it owns observable delete requests. + // Reloading would discard the only truthful success/error observer. + var reloadedAfterFailure = stillPending.length ? false : reloadTerminal(opts); + return { + success: false, + reason: 'partial_reset', + blocked: blockedDatabases.length > 0, + blockedDatabases: blockedDatabases, + deletionPending: stillPending.length > 0, + pendingDatabases: stillPending, + markerPersisted: markerPersisted, + deletionState: stillPending.length ? 'pending' : 'retired', + retryable: true, + // `terminal` means "this page was actually torn down". Callers + // use it to decide whether they still own a live document, so + // reporting a reload that never happened strands them on a + // page they believe is gone. + terminal: reloadedAfterFailure, + errors: errors, + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; } + var reloaded = reloadTerminal(opts); + return { + success: true, + terminal: reloaded, + deletionState: 'retired', + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; + })(); + try { + var outcome = await resetPromise; + // The singleton exists only to collapse duplicate clicks on one + // in-flight run; it is not a result cache. Anything already settled + // must be released, or the next click replays a stale outcome without + // clearing a single byte. + // + // The one case worth keeping is a reset that really did call + // location.reload(): the document is being torn down, and holding the + // resolved promise suppresses clicks landing in that teardown window + // rather than firing a second delete against a dying realm. Reload is + // asynchronous, so those clicks are genuinely reachable. + if (!outcome || outcome.terminal !== true) resetPromise = null; + return outcome; + } catch (error) { + resetPromise = null; + throw error; } - return () => listeners.delete(callback); - } - - function getCurrentProviders() { - return providers ? Object.assign({}, providers) : null; - } - - window.StorageProviderRegistry = { - registerStorageProviders, - onProvidersReady, - getCurrentProviders - }; -})(window); - - -/* ===== js/data/dataSources/storageDataSource.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - function isProtectedPracticeDataKey(key) { - return key === 'practice_records' || key === 'user_stats'; } - class StorageTransactionContext { - constructor(storageManager, options = {}) { - this.storage = storageManager; - this.createInternalOptions = typeof options.createInternalOptions === 'function' - ? options.createInternalOptions - : null; - this.operations = []; - this.cache = new Map(); - } - - _internalOptions(key) { - if (this.createInternalOptions) { - return this.createInternalOptions(); + async function request(options) { + var opts = options || {}; + // Checked before the confirm dialog: asking the user to authorise a + // destructive action we are about to refuse is worse than useless, and a + // second `deleteDatabase()` for a name that is already queued only grows + // the un-cancellable backlog. + var blockedByPending = pendingDeletionBlock(opts); + if (blockedByPending) { + if (blockedByPending.recoveryConfirmationRequired) { + var recoveryConfirmed = false; + try { + recoveryConfirmed = global.confirm( + '浏览器记录显示上一次数据库删除可能仍在等待。继续恢复会重新排队删除,' + + '请先关闭其他 IELTS Atlas 标签页;确定继续吗?' + ); + } catch (_) { recoveryConfirmed = false; } + if (recoveryConfirmed) { + opts = Object.assign({}, opts, { recoveryConfirmed: true }); + } else { + notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); + return blockedByPending; + } + } else { + notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); + return blockedByPending; } - if (isProtectedPracticeDataKey(key)) { - throw new Error(`StorageTransactionContext cannot access protected key ${key} without internal storage access`); + } + var confirmed = opts.confirmed === true; + if (!confirmed) { + try { + confirmed = global.confirm( + '确定要清除全部浏览器本地数据并返回首次启动状态吗?\n\n' + + '练习记录、题库、词汇、设置、应用内备份和本地文件夹绑定都会清除;' + + '外部文件夹中的 JSON 备份不会删除。' + ); + } catch (_) { + confirmed = false; } - return { skipPracticeCoreRedirect: true }; } + if (!confirmed) return { + success: false, + reason: 'cancelled', + deletionState: currentDeletionState() + }; - async get(key, defaultValue) { - if (this.cache.has(key)) { - return this.cache.get(key); + notify('正在清除全部本地数据…', 'info'); + try { + return await perform(opts); + } catch (error) { + if (global.console && typeof global.console.error === 'function') { + global.console.error('[SiteDataReset] full reset failed:', error); } - const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue; - const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key)); - const finalValue = value === undefined ? resolvedDefault : value; - this.cache.set(key, finalValue); - return finalValue; + notify('清除失败:' + (error && error.message ? error.message : '浏览器存储不可用'), 'error'); + return { + success: false, + reason: 'reset_failed', + deletionState: currentDeletionState(), + error: error + }; } + } - set(key, value) { - this.cache.set(key, value); - this.operations.push({ type: 'set', key, value }); + /** + * Adopt a marker written before the last reload and warn once. + * + * It never issues a delete. It does require explicit confirmation before a + * recovery reset, so stale evidence remains recoverable without being treated + * as proof that the late-deletion hazard disappeared. + * + * The warning is deferred because this module ships in core-foundation, + * which index.html loads *before* the ui-shell/legacy bundles that define + * `showMessage`. Warning synchronously would route the one notice the user + * actually needs into console.log instead of the message center. + */ + function adoptPendingDeletionsFromPreviousPage() { + adoptedPendingMarkerState = readPendingDeletionMarker(); + adoptedPendingDatabases = adoptedPendingMarkerState.databases; + if (!adoptedPendingDatabases.length) return; + var announced = false; + function announce() { + if (announced) return; + announced = true; + // Re-read: a reset may have completed and retired the marker while we + // were waiting for the UI layer to come up. + if (!adoptedPendingDatabases.length) return; + notify(pendingDeletionMessage(adoptedPendingDatabases), 'warning'); } - - remove(key) { - this.cache.delete(key); - this.operations.push({ type: 'remove', key }); + if (typeof global.showMessage === 'function') { + announce(); + return; } - - async commit() { - for (const op of this.operations) { - if (op.type === 'set') { - await this.storage.set(op.key, op.value, this._internalOptions(op.key)); - } else if (op.type === 'remove') { - await this.storage.remove(op.key, this._internalOptions(op.key)); - } + var attempts = 0; + function poll() { + attempts += 1; + if (typeof global.showMessage === 'function' || attempts >= 20) { + announce(); + return; } - this.operations = []; - } - - async rollback() { - this.operations = []; + hostSetTimeout(poll, 250); } + if (!hostSetTimeout(poll, 250)) announce(); } - class StorageDataSource { - constructor(storageManager, options = {}) { - if (!storageManager) { - throw new Error('StorageDataSource requires a StorageManager instance'); - } - this.storage = storageManager; - this.createInternalOptions = typeof options.createInternalOptions === 'function' - ? options.createInternalOptions - : null; - this._queue = Promise.resolve(); - } - - _internalOptions(key) { - if (this.createInternalOptions) { - return this.createInternalOptions(); - } - if (isProtectedPracticeDataKey(key)) { - throw new Error(`StorageDataSource cannot access protected key ${key} without internal storage access`); - } - return { skipPracticeCoreRedirect: true }; - } - - async read(key, defaultValue) { - const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue; - const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key)); - return value === undefined ? resolvedDefault : value; - } - - async write(key, value) { - return this._enqueue(async () => { - await this.storage.set(key, value, this._internalOptions(key)); - return true; - }); - } + global.SiteDataReset = Object.freeze({ + __v2: true, + DATABASE_NAMES: DATABASE_NAMES, + PENDING_DELETION_MARKER_KEY: PENDING_DELETION_MARKER_KEY, + deleteDatabaseStrict: deleteDatabaseStrict, + perform: perform, + request: request, + /** Names whose un-cancellable deletion has not reported back yet. */ + pendingDeletions: listPendingDeletions, + /** True while a previous deletion is still armed; see `pendingDeletions`. */ + isDeletionPending: function () { + return listPendingDeletions().length > 0; + }, + recoveryConfirmationRequired: function () { + return adoptedPendingDatabases.length > 0; + }, + deletionState: currentDeletionState + }); + global.clearCache = request; + adoptPendingDeletionsFromPreviousPage(); +})(typeof window !== 'undefined' ? window : globalThis); - async remove(key) { - return this._enqueue(async () => { - await this.storage.remove(key, this._internalOptions(key)); - return true; - }); - } - async runTransaction(handler, options = {}) { - if (typeof handler !== 'function') { - throw new Error('StorageDataSource.runTransaction requires a handler function'); - } - const label = options.label || 'storage-transaction'; - return this._enqueue(async () => { - const context = new StorageTransactionContext(this.storage, { - createInternalOptions: this.createInternalOptions - }); - try { - const result = await handler(context); - await context.commit(); - return result; - } catch (error) { - await context.rollback(); - console.error(`[StorageDataSource] Transaction failed (${label}):`, error); - throw error; - } - }); - } +/* ===== js/core/practiceCore.js ===== */ +(function initPracticeCore(global) { + 'use strict'; - _enqueue(task) { - const next = this._queue.then(task); - this._queue = next.catch(() => {}); - return next; - } + if (global.PracticeCore && global.PracticeCore.__stable === true) { + return; } - ExamData.StorageTransactionContext = StorageTransactionContext; - ExamData.StorageDataSource = StorageDataSource; -})(window); + const MESSAGE_TYPE_ALIASES = Object.freeze({ + practice_complete: 'PRACTICE_COMPLETE', + practice_completed: 'PRACTICE_COMPLETE', + PracticeComplete: 'PRACTICE_COMPLETE', + SESSION_COMPLETE: 'PRACTICE_COMPLETE', + session_complete: 'PRACTICE_COMPLETE', + session_completed: 'PRACTICE_COMPLETE', + EXAM_FINISHED: 'PRACTICE_COMPLETE', + QUIZ_COMPLETE: 'PRACTICE_COMPLETE', + QUIZ_COMPLETED: 'PRACTICE_COMPLETE', + TEST_COMPLETE: 'PRACTICE_COMPLETE', + LESSON_COMPLETE: 'PRACTICE_COMPLETE', + WORKOUT_COMPLETE: 'PRACTICE_COMPLETE', + SESSION_READY: 'SESSION_READY', + session_ready: 'SESSION_READY', + EXAM_COMPLETED: 'exam_completed', + EXAM_PROGRESS: 'exam_progress', + EXAM_ERROR: 'exam_error', + progress_update: 'PROGRESS_UPDATE', + SESSION_PROGRESS: 'PROGRESS_UPDATE', + session_progress: 'PROGRESS_UPDATE', + practice_progress: 'PROGRESS_UPDATE', + SESSION_ERROR: 'ERROR_OCCURRED', + session_error: 'ERROR_OCCURRED', + practice_error: 'ERROR_OCCURRED', + REQUEST_INIT: 'REQUEST_INIT', + request_init: 'REQUEST_INIT', + REQUEST_SESSION_INIT: 'REQUEST_INIT', + INIT_SESSION: 'INIT_SESSION', + init_session: 'INIT_SESSION' + }); + const PRACTICE_COMPLETE_TYPES = new Set([ + 'PRACTICE_COMPLETE', + 'PRACTICE_COMPLETED', + 'SESSION_COMPLETE', + 'SESSION_COMPLETED', + 'EXAM_FINISHED', + 'QUIZ_COMPLETE', + 'QUIZ_COMPLETED', + 'TEST_COMPLETE', + 'LESSON_COMPLETE', + 'WORKOUT_COMPLETE' + ]); -/* ===== js/data/repositories/baseRepository.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; + function isPlainObject(value) { + return value && typeof value === 'object' && !Array.isArray(value); + } - function cloneValue(value) { - if (value === null || value === undefined) { - return value; - } - if (typeof structuredClone === 'function') { - try { - return structuredClone(value); - } catch (_) { - // Fallback to JSON serialization below - } + function safeParseJson(value) { + if (typeof value !== 'string') { + return null; } try { - return JSON.parse(JSON.stringify(value)); + return JSON.parse(value); } catch (_) { - return value; + return null; } } - class BaseRepository { - constructor(options) { - const { - dataSource, - key, - name, - defaultValue = null, - migrations = [], - validators = [], - cloneOnRead = true - } = options || {}; - - if (!dataSource) { - throw new Error('BaseRepository requires a dataSource instance'); - } - if (!key) { - throw new Error('BaseRepository requires a storage key'); - } - - this.dataSource = dataSource; - this.key = key; - this.name = name || key; - this.defaultValue = defaultValue; - this.migrations = Array.isArray(migrations) ? migrations.slice() : [migrations]; - this.validators = Array.isArray(validators) ? validators.slice() : [validators]; - this.cloneOnRead = cloneOnRead; + function clonePlainObject(value) { + if (value == null || typeof value !== 'object') { + return value ?? null; } - - _resolveDefaultValue(override) { - const candidate = override !== undefined ? override : this.defaultValue; - return typeof candidate === 'function' ? candidate() : candidate; + if (Array.isArray(value)) { + return value.map((item) => clonePlainObject(item)).filter((item) => item !== undefined); } + const clone = {}; + Object.keys(value).forEach((key) => { + clone[key] = clonePlainObject(value[key]); + }); + return clone; + } - async read(options = {}) { - const { transaction, defaultValue, skipValidation = false, clone = undefined } = options; - const resolvedDefault = this._resolveDefaultValue(defaultValue); - const sourceValue = transaction - ? await transaction.get(this.key, resolvedDefault) - : await this.dataSource.read(this.key, resolvedDefault); + /** + * Resolve the complete reading-annotation snapshot from canonical and legacy + * locations. Explicit root values win so review edits can replace an older + * realData mirror; the returned object is deep-cloned and safe to persist. + */ + function resolveAnnotationState(recordData = {}, fallbackSources = [], options = {}) { + const root = isPlainObject(recordData) ? recordData : {}; + const rawData = isPlainObject(root.rawData) ? root.rawData : {}; + const realData = isPlainObject(root.realData) ? root.realData : {}; + const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; + const sources = [root, rawData, realData, rawRealData] + .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources]) + .filter((source) => isPlainObject(source)); + + const pickArray = (field) => { + const source = sources.find((candidate) => ( + Array.isArray(candidate[field]) + && (!options.preferNonEmptyArrays || candidate[field].length) + )) || sources.find((candidate) => Array.isArray(candidate[field])); + return source ? clonePlainObject(source[field]) : []; + }; + const pickString = (field) => { + const source = sources.find((candidate) => typeof candidate[field] === 'string'); + return source ? source[field] : ''; + }; + const scrollSource = sources.find((candidate) => ( + candidate.scrollY !== undefined + && candidate.scrollY !== null + && Number.isFinite(Number(candidate.scrollY)) + )); - let value = sourceValue === undefined ? resolvedDefault : sourceValue; - value = await this.applyMigrations(value, { transaction }); + return { + highlights: pickArray('highlights'), + markedQuestions: pickArray('markedQuestions'), + noteText: pickString('noteText'), + notes: pickArray('notes'), + noteOutlines: pickArray('noteOutlines'), + scrollY: scrollSource ? Number(scrollSource.scrollY) : 0 + }; + } - if (!skipValidation) { - this.validate(value); - } + function ensureNumber(value, fallback = 0) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; + } - if (clone === false || (!this.cloneOnRead && clone === undefined)) { - return value; - } - return cloneValue(value); + function normalizeDateCandidate(value) { + if (!value) { + return null; } - - async write(value, options = {}) { - const { transaction, skipValidation = false, clone = true } = options; - if (!skipValidation) { - this.validate(value); - } - const dataToPersist = clone ? cloneValue(value) : value; - if (transaction) { - transaction.set(this.key, dataToPersist); - return true; - } - await this.dataSource.write(this.key, dataToPersist); - return true; + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value.toISOString(); } - - async remove(options = {}) { - const { transaction } = options; - if (transaction) { - transaction.remove(this.key); - return true; - } - await this.dataSource.remove(this.key); - return true; + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value).toISOString(); } - - async applyMigrations(value, context = {}) { - let current = value; - for (const migration of this.migrations) { - if (typeof migration === 'function') { - current = await migration(current, { key: this.key, name: this.name, ...context }); - } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; } - return current; - } - - validate(value) { - const errors = []; - for (const validator of this.validators) { - if (typeof validator !== 'function') { - continue; - } - try { - const result = validator(value); - if (result === false) { - errors.push(`${this.name} 数据验证失败`); - } else if (typeof result === 'string') { - errors.push(result); - } else if (result && typeof result === 'object') { - if (result.valid === false || result.isValid === false) { - errors.push(result.message || result.error || `${this.name} 数据验证失败`); - } - } - } catch (error) { - errors.push(error.message || String(error)); + if (/^\d+$/.test(trimmed)) { + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + return new Date(trimmed.length > 10 ? numeric : numeric * 1000).toISOString(); } } - if (errors.length > 0) { - const err = new Error(`[${this.name}] 数据验证失败: ${errors.join('; ')}`); - err.validationErrors = errors; - throw err; + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); } - return true; } + return null; + } - async runConsistencyCheck(options = {}) { - try { - const data = await this.read({ ...options, skipValidation: false }); - return { valid: true, data, errors: [] }; - } catch (error) { - const errors = error.validationErrors || [error.message || String(error)]; - return { valid: false, errors }; + function firstDateCandidate() { + for (let index = 0; index < arguments.length; index += 1) { + const normalized = normalizeDateCandidate(arguments[index]); + if (normalized) { + return normalized; } } + return null; + } - registerMigration(fn) { - if (typeof fn === 'function') { - this.migrations.push(fn); + function firstStringCandidate() { + for (let index = 0; index < arguments.length; index += 1) { + const value = arguments[index]; + if (value === undefined || value === null) { + continue; } - } - - registerValidator(fn) { - if (typeof fn === 'function') { - this.validators.push(fn); + const trimmed = String(value).trim(); + if (trimmed) { + return trimmed; } } + return null; } - ExamData.cloneValue = cloneValue; - ExamData.BaseRepository = BaseRepository; -})(window); - - -/* ===== js/data/repositories/dataRepositoryRegistry.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - class DataRepositoryRegistry { - constructor(dataSource) { - if (!dataSource) { - throw new Error('DataRepositoryRegistry requires a dataSource instance'); - } - this.dataSource = dataSource; - this._repositories = new Map(); - } + function resolveDurationSeconds(recordData = {}, startTime = null, endTime = null) { + const realData = isPlainObject(recordData.realData) ? recordData.realData : {}; + const scoreInfo = isPlainObject(recordData.scoreInfo) + ? recordData.scoreInfo + : (isPlainObject(realData.scoreInfo) ? realData.scoreInfo : {}); + const candidates = [ + recordData.duration, + realData.duration, + recordData.durationSeconds, + recordData.duration_seconds, + recordData.elapsedSeconds, + recordData.elapsed_seconds, + recordData.timeSpent, + recordData.time_spent, + realData.durationSeconds, + realData.elapsedSeconds, + realData.timeSpent, + scoreInfo.duration, + scoreInfo.timeSpent + ]; - register(name, repository) { - if (!name) { - throw new Error('Repository name is required'); - } - if (!repository) { - throw new Error(`Repository instance missing for ${name}`); + for (let index = 0; index < candidates.length; index += 1) { + const numeric = Number(candidates[index]); + if (Number.isFinite(numeric) && numeric > 0) { + return numeric; } - this._repositories.set(name, repository); - } - - get(name) { - return this._repositories.get(name); } - listNames() { - return Array.from(this._repositories.keys()); + const start = startTime ? new Date(startTime).getTime() : NaN; + const end = endTime ? new Date(endTime).getTime() : NaN; + if (Number.isFinite(start) && Number.isFinite(end) && end > start) { + return Math.round((end - start) / 1000); } - async transaction(names, handler) { - if (typeof handler !== 'function') { - throw new Error('transaction handler must be a function'); - } - const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames(); - return this.dataSource.runTransaction(async (tx) => { - const scope = {}; - for (const name of targetNames) { - if (this._repositories.has(name)) { - scope[name] = this._repositories.get(name); - } + if (Array.isArray(realData.interactions) && realData.interactions.length) { + const timestamps = realData.interactions + .map(item => item && Number(item.timestamp)) + .filter(value => Number.isFinite(value)); + if (timestamps.length) { + const span = Math.max(...timestamps) - Math.min(...timestamps); + if (Number.isFinite(span) && span > 0) { + return Math.floor(span / 1000); } - return handler(scope, tx); - }, { label: `registry:${targetNames.join(',')}` }); + } } - async runConsistencyChecks(names) { - const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames(); - const report = {}; - for (const name of targetNames) { - const repo = this._repositories.get(name); - if (repo && typeof repo.runConsistencyCheck === 'function') { - try { - report[name] = await repo.runConsistencyCheck(); - } catch (error) { - report[name] = { - valid: false, - errors: [error.message || String(error)] - }; - } - } + for (let index = 0; index < candidates.length; index += 1) { + const numeric = Number(candidates[index]); + if (Number.isFinite(numeric) && numeric >= 0) { + return numeric; } - return report; } - } - - ExamData.DataRepositoryRegistry = DataRepositoryRegistry; -})(window); - - -/* ===== js/data/repositories/practiceRepository.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - function ensureArray(value) { - return Array.isArray(value) ? value : []; + return 0; } - class PracticeRepository extends BaseRepository { - constructor(dataSource, options = {}) { - super({ - dataSource, - key: options.key || 'practice_records', - name: options.name || 'practice_records', - defaultValue: () => [], - migrations: [ - (value) => ensureArray(value), - ...(options.migrations || []) - ], - validators: [ - (value) => Array.isArray(value) || 'practice_records 必须为数组', - ...(options.validators || []) - ], - cloneOnRead: options.cloneOnRead !== false - }); - this.maxRecords = options.maxRecords || 1000; - } - - normalizeRecord(record) { - if (!record || typeof record !== 'object') { - throw new Error('practice record 必须是对象'); - } - const normalized = { ...record }; - if (!normalized.id) { - normalized.id = `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - } else { - normalized.id = String(normalized.id); - } - return normalized; - } - - validatePracticeRecord(record) { - const errors = []; - if (!record || typeof record !== 'object') { - errors.push('记录必须是对象'); - } else { - if (!record.id || typeof record.id !== 'string') { - errors.push('记录缺少有效的 id'); - } - if (!record.type || typeof record.type !== 'string') { - errors.push('记录缺少有效的 type'); - } - if (record.score === undefined || record.score === null || typeof record.score !== 'number') { - errors.push('记录缺少有效的 score'); - } - if (record.score !== undefined && typeof record.score !== 'number') { - errors.push('score 必须是数字'); - } - if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') { - errors.push('totalQuestions 必须是数字'); - } - if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') { - errors.push('correctAnswers 必须是数字'); - } - if (record.duration !== undefined && typeof record.duration !== 'number') { - errors.push('duration 必须是数字'); - } - if (!record.date) { - errors.push('记录缺少有效的 date'); - } else if (Number.isNaN(new Date(record.date).getTime())) { - errors.push('date 格式无效'); - } - } - return { - isValid: errors.length === 0, - errors - }; - } - - _assertRecord(record) { - const validation = this.validatePracticeRecord(record); - if (!validation.isValid) { - const error = new Error(`[practice_records] 记录无效: ${validation.errors.join(', ')}`); - error.validationErrors = validation.errors; - throw error; - } - return true; - } - - async list(options = {}) { - return await this.read({ ...options, clone: options.clone !== false }); - } - - async getById(id, options = {}) { - const records = await this.read({ ...options, clone: true }); - return records.find(r => r.id === id) || null; - } + function normalizePracticeType(rawType) { + if (!rawType) return null; + const normalized = String(rawType).toLowerCase(); + if (normalized.includes('listen')) return 'listening'; + if (normalized.includes('read')) return 'reading'; + return null; + } - async overwrite(records, options = {}) { - const list = ensureArray(records).map((record) => { - const normalized = this.normalizeRecord(record); - this._assertRecord(normalized); - return normalized; - }); - await this.write(list, { ...options, skipValidation: true }); - return true; - } + function resolveRecordDate(recordData = {}, now = new Date().toISOString()) { + const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {}; + const candidates = [ + metadata.date, + recordData.date, + recordData.endTime, + recordData.end_time, + recordData.completedAt, + recordData.finishedAt, + recordData.finishTime, + recordData.startTime, + recordData.start_time, + recordData.startedAt, + recordData.createdAt, + recordData.timestamp, + now + ]; - async upsert(record, options = {}) { - const normalized = this.normalizeRecord(record); - this._assertRecord(normalized); - const merge = options.merge === true; - return this.dataSource.runTransaction(async (tx) => { - let records = await this.read({ transaction: tx, skipValidation: true, clone: true }); - records = ensureArray(records); - const index = records.findIndex(r => r.id === normalized.id); - if (index >= 0) { - records[index] = merge ? { ...records[index], ...normalized } : normalized; - } else { - records.unshift(normalized); - } - if (this.maxRecords && records.length > this.maxRecords) { - records = records.slice(0, this.maxRecords); - } - await this.write(records, { transaction: tx, skipValidation: true, clone: false }); + for (let i = 0; i < candidates.length; i += 1) { + const normalized = normalizeDateCandidate(candidates[i]); + if (normalized) { return normalized; - }, { label: 'practice-upsert' }); - } - - async removeById(id, options = {}) { - if (!id) return 0; - const removed = await this.removeByIds([id], options); - return removed; - } - - async removeByIds(ids, options = {}) { - const idSet = new Set((ids || []).filter(Boolean).map(String)); - if (idSet.size === 0) { - return 0; } - return this.dataSource.runTransaction(async (tx) => { - let records = await this.read({ transaction: tx, skipValidation: true, clone: true }); - records = ensureArray(records); - const next = records.filter(record => !idSet.has(record.id)); - const removed = records.length - next.length; - if (removed > 0) { - await this.write(next, { transaction: tx, skipValidation: true, clone: false }); - } - return removed; - }, { label: 'practice-remove' }); } - async update(id, updates = {}, options = {}) { - if (!id) { - throw new Error('update 需要记录 id'); - } - if (!updates || typeof updates !== 'object') { - throw new Error('updates 必须是对象'); - } - return this.dataSource.runTransaction(async (tx) => { - let records = await this.read({ transaction: tx, skipValidation: true, clone: true }); - records = ensureArray(records); - const index = records.findIndex(record => record.id === String(id)); - if (index === -1) { - return null; - } - const updated = { ...records[index], ...updates }; - this._assertRecord(updated); - records[index] = updated; - await this.write(records, { transaction: tx, skipValidation: true, clone: false }); - return updated; - }, { label: 'practice-update' }); - } + return now; + } - async count(options = {}) { - const records = await this.read({ ...options, clone: false, skipValidation: false }); - return Array.isArray(records) ? records.length : 0; + function inferExamId(recordData = {}) { + if (!recordData || typeof recordData !== 'object') { + return null; } - async clear(options = {}) { - await this.write([], { ...options, skipValidation: true }); - return true; + const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {}; + const direct = firstStringCandidate( + recordData.examId, + recordData.exam_id, + recordData.examID, + metadata.examId, + metadata.exam_id + ); + if (direct) { + return direct; } - - async runConsistencyCheck(options = {}) { - const report = await super.runConsistencyCheck(options); - if (!report.valid) { - return report; - } - const errors = []; - const records = ensureArray(report.data); - for (const record of records) { - const validation = this.validatePracticeRecord(record); - if (!validation.isValid) { - errors.push(`记录 ${record && record.id ? record.id : 'unknown'}: ${validation.errors.join(', ')}`); - } + if (Array.isArray(recordData.suiteEntries)) { + const suiteExam = recordData.suiteEntries.find((entry) => entry && entry.examId); + if (suiteExam) { + return suiteExam.examId; } - if (errors.length > 0) { - return { valid: false, errors }; + } + if (typeof recordData.id === 'string') { + const match = recordData.id.match(/^record_([^_]+)_/); + if (match && match[1]) { + return match[1]; } - return { valid: true, data: records, errors: [] }; } - } - - ExamData.PracticeRepository = PracticeRepository; -})(window); - - -/* ===== js/data/repositories/settingsRepository.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - function ensureObject(value) { - return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + return null; } - class SettingsRepository extends BaseRepository { - constructor(dataSource, options = {}) { - super({ - dataSource, - key: options.key || 'user_settings', - name: options.name || 'user_settings', - defaultValue: () => ({}), - migrations: [ - (value) => ensureObject(value), - ...(options.migrations || []) - ], - validators: [ - (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_settings 必须是对象', - ...(options.validators || []) - ], - cloneOnRead: options.cloneOnRead !== false - }); - } - - async getAll(options = {}) { - return await this.read({ ...options, clone: options.clone !== false }); + function normalizeAnswerValue(value) { + const sanitizer = global.AnswerSanitizer; + if (sanitizer && typeof sanitizer.normalizeValue === 'function') { + return sanitizer.normalizeValue(value); } - async saveAll(settings, options = {}) { - const prepared = ensureObject(settings); - await this.write(prepared, { ...options, skipValidation: false }); - return true; + if (value === undefined || value === null) { + return ''; } - - async get(key, defaultValue = null, options = {}) { - const settings = await this.read({ ...options, clone: true }); - if (Object.prototype.hasOwnProperty.call(settings, key)) { - return settings[key]; - } - return typeof defaultValue === 'function' ? defaultValue() : defaultValue; + if (typeof value === 'string') { + const trimmed = value.trim(); + return /^\[object\s/i.test(trimmed) ? '' : trimmed; } - - async set(key, value, options = {}) { - return this.merge({ [key]: value }, options); + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).trim(); } - - async merge(patch, options = {}) { - if (!patch || typeof patch !== 'object') { - throw new Error('merge 需要对象参数'); - } - return this.dataSource.runTransaction(async (tx) => { - const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true })); - const next = { ...current, ...patch }; - await this.write(next, { transaction: tx, skipValidation: false, clone: false }); - return next; - }, { label: 'settings-merge' }); + if (Array.isArray(value)) { + return value.map((item) => normalizeAnswerValue(item)).filter(Boolean).join(','); } - - async removeKey(key, options = {}) { - return this.dataSource.runTransaction(async (tx) => { - const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true })); - if (!Object.prototype.hasOwnProperty.call(current, key)) { - return current; + if (typeof value === 'object') { + const preferKeys = ['value', 'label', 'text', 'answer', 'content', 'userAnswer', 'correctAnswer']; + for (let i = 0; i < preferKeys.length; i += 1) { + const entry = value[preferKeys[i]]; + if (typeof entry === 'string') { + const trimmed = entry.trim(); + if (trimmed && !/^\[object\s/i.test(trimmed)) { + return trimmed; + } } - delete current[key]; - await this.write(current, { transaction: tx, skipValidation: false, clone: false }); - return current; - }, { label: 'settings-remove-key' }); - } - - async clear(options = {}) { - await this.write({}, { ...options, skipValidation: true }); - return true; - } - } - - ExamData.SettingsRepository = SettingsRepository; -})(window); - - -/* ===== js/data/repositories/backupRepository.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - - function ensureArray(value) { - return Array.isArray(value) ? value : []; - } - - class BackupRepository extends BaseRepository { - constructor(dataSource, options = {}) { - super({ - dataSource, - key: options.key || 'manual_backups', - name: options.name || 'manual_backups', - defaultValue: () => [], - migrations: [ - (value) => ensureArray(value), - ...(options.migrations || []) - ], - validators: [ - (value) => Array.isArray(value) || 'manual_backups 必须是数组', - ...(options.validators || []) - ], - cloneOnRead: options.cloneOnRead !== false - }); - this.maxBackups = options.maxBackups || 20; - } - - normalizeBackup(backup) { - if (!backup || typeof backup !== 'object') { - throw new Error('备份数据必须是对象'); } - const normalized = { ...backup }; - normalized.id = normalized.id ? String(normalized.id) : `backup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - normalized.timestamp = normalized.timestamp || new Date().toISOString(); - normalized.type = normalized.type || 'manual'; - normalized.version = normalized.version || '0.6.2-fix'; - normalized.data = normalized.data || {}; - normalized.size = normalized.size || JSON.stringify(normalized.data).length; - return normalized; - } - - validateBackup(backup) { - const errors = []; - if (!backup || typeof backup !== 'object') { - errors.push('备份必须是对象'); - } else { - if (!backup.id) { - errors.push('备份缺少 id'); - } - if (!backup.timestamp) { - errors.push('备份缺少 timestamp'); - } - if (!backup.data || typeof backup.data !== 'object') { - errors.push('备份缺少 data 对象'); + if (typeof value.innerText === 'string') { + const text = value.innerText.trim(); + if (text && !/^\[object\s/i.test(text)) { + return text; } } - return { - isValid: errors.length === 0, - errors - }; - } - - _assertBackup(backup) { - const validation = this.validateBackup(backup); - if (!validation.isValid) { - const error = new Error(`[manual_backups] 备份无效: ${validation.errors.join(', ')}`); - error.validationErrors = validation.errors; - throw error; + if (typeof value.textContent === 'string') { + const text = value.textContent.trim(); + if (text && !/^\[object\s/i.test(text)) { + return text; + } } - return true; + return ''; } - async list(options = {}) { - return await this.read({ ...options, clone: options.clone !== false }); - } + return String(value).trim(); + } - async add(backup, options = {}) { - const normalized = this.normalizeBackup(backup); - this._assertBackup(normalized); - return this.dataSource.runTransaction(async (tx) => { - let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true })); - backups.unshift(normalized); - if (this.maxBackups && backups.length > this.maxBackups) { - backups = backups.slice(0, this.maxBackups); - } - await this.write(backups, { transaction: tx, skipValidation: true, clone: false }); - return normalized; - }, { label: 'backup-add' }); - } + function isNoiseKey(key) { + if (!key) return true; - async saveAll(backups, options = {}) { - const prepared = ensureArray(backups).map((item) => { - const normalized = this.normalizeBackup(item); - this._assertBackup(normalized); - return normalized; - }); - await this.write(prepared, { ...options, skipValidation: true }); + const keyStr = String(key).toLowerCase(); + const noiseKeys = [ + 'playback-speed', 'playbackspeed', 'volume-slider', 'volumeslider', + 'audio-volume', 'audiocurrenttime', 'audio-duration', 'audioduration', + 'settings', 'lastfocuselement', 'sessionid', 'examid', + 'nextexamid', 'previousexamid', 'folder', 'source', 'result', + 'metadata', 'practicesettings', 'config', 'state' + ]; + if (noiseKeys.includes(keyStr)) { return true; } - async delete(id, options = {}) { - if (!id) return false; - const targetId = String(id); - return this.dataSource.runTransaction(async (tx) => { - let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true })); - const next = backups.filter(backup => backup.id !== targetId); - const deleted = next.length !== backups.length; - if (deleted) { - await this.write(next, { transaction: tx, skipValidation: true, clone: false }); - } - return deleted; - }, { label: 'backup-delete' }); + const noisePatterns = [ + /playback/i, /volume/i, /slider/i, /speed/i, + /audio/i, /duration/i, /config/i, /setting/i + ]; + for (let i = 0; i < noisePatterns.length; i += 1) { + if (noisePatterns[i].test(keyStr)) { + return true; + } } - async getById(id, options = {}) { - if (!id) return null; - const backups = await this.read({ ...options, clone: true }); - return backups.find(backup => backup.id === String(id)) || null; + const questionMatch = keyStr.match(/q?(\d+)/); + if (questionMatch) { + const number = parseInt(questionMatch[1], 10); + if (number < 1 || number > 200) { + return true; + } } - async clear(options = {}) { - await this.write([], { ...options, skipValidation: true }); - return true; - } + return false; + } - async prune(limit, options = {}) { - const max = typeof limit === 'number' && limit > 0 ? limit : this.maxBackups; - return this.dataSource.runTransaction(async (tx) => { - let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true })); - if (backups.length <= max) { - return backups.length; - } - const next = backups.slice(0, max); - await this.write(next, { transaction: tx, skipValidation: true, clone: false }); - return next.length; - }, { label: 'backup-prune' }); + function normalizeQuestionKey(rawKey, index) { + if (rawKey == null || rawKey === '') { + return `q${index + 1}`; } + const key = String(rawKey).trim(); + return key.startsWith('q') ? key : `q${key}`; } - ExamData.BackupRepository = BackupRepository; -})(window); - - -/* ===== js/data/repositories/metaRepository.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - const BaseRepository = ExamData.BaseRepository; - - class MetaRepository { - constructor(dataSource, definitions = {}) { - if (!dataSource) { - throw new Error('MetaRepository requires a dataSource instance'); - } - this.dataSource = dataSource; - this.repositories = new Map(); - Object.entries(definitions).forEach(([key, config]) => { - this.registerKey(key, config); - }); + function normalizeReplayQuestionKey(rawKey, index) { + if (rawKey == null || rawKey === '') { + return Number.isInteger(index) ? `q${index + 1}` : ''; } - - registerKey(key, config = {}) { - const repository = new BaseRepository({ - dataSource: this.dataSource, - key, - name: config.name || `meta:${key}`, - defaultValue: config.defaultValue !== undefined ? config.defaultValue : null, - migrations: config.migrations || [], - validators: config.validators || [], - cloneOnRead: config.cloneOnRead !== false - }); - this.repositories.set(key, repository); - return repository; + const raw = String(rawKey).trim(); + if (!raw) { + return Number.isInteger(index) ? `q${index + 1}` : ''; } - - _getRepo(key) { - const repo = this.repositories.get(key); - if (!repo) { - throw new Error(`MetaRepository 未注册键: ${key}`); - } - return repo; + const splitIndex = raw.lastIndexOf('::'); + const value = splitIndex >= 0 ? raw.slice(splitIndex + 2).trim() : raw; + if (!value) { + return Number.isInteger(index) ? `q${index + 1}` : ''; } - - async get(key, defaultValue, options = {}) { - const repo = this._getRepo(key); - const resolvedDefault = defaultValue !== undefined ? defaultValue : undefined; - return repo.read({ ...options, defaultValue: resolvedDefault, clone: options.clone !== false }); + const explicitQuestion = value.match(/^q\s*[-_ ]?(\d+)$/i) || value.match(/\bq\s*[-_ ]?(\d+)\b/i); + if (explicitQuestion) { + return `q${explicitQuestion[1]}`; } - - async set(key, value, options = {}) { - const repo = this._getRepo(key); - await repo.write(value, { ...options, skipValidation: false, clone: options.clone !== false }); - return true; + if (/^\d+$/.test(value)) { + return `q${value}`; } - - async remove(key, options = {}) { - const repo = this._getRepo(key); - await repo.remove(options); - return true; + const trailingNumber = value.match(/(\d+)(?!.*\d)/); + if (trailingNumber) { + return `q${trailingNumber[1]}`; } + return value.toLowerCase(); + } - async runConsistencyCheck(keys) { - const targetKeys = Array.isArray(keys) && keys.length ? keys : Array.from(this.repositories.keys()); - const report = {}; - for (const key of targetKeys) { - const repo = this.repositories.get(key); - if (!repo) continue; - report[key] = await repo.runConsistencyCheck(); - } - return report; + function normalizeReplayMap(rawMap = {}) { + const normalized = {}; + if (Array.isArray(rawMap)) { + rawMap.forEach((entry, index) => { + if (entry == null) { + return; + } + if (typeof entry !== 'object') { + normalized[`q${index + 1}`] = entry; + return; + } + const normalizedKey = normalizeReplayQuestionKey( + entry.questionId ?? entry.question ?? entry.id, + index + ); + if (!normalizedKey) { + return; + } + const hasAnswerValue = Object.prototype.hasOwnProperty.call(entry, 'answer') + || Object.prototype.hasOwnProperty.call(entry, 'value'); + const isComparisonEntry = !hasAnswerValue && ( + Object.prototype.hasOwnProperty.call(entry, 'userAnswer') + || Object.prototype.hasOwnProperty.call(entry, 'correctAnswer') + || Object.prototype.hasOwnProperty.call(entry, 'isCorrect') + ); + normalized[normalizedKey] = isComparisonEntry + ? clonePlainObject(entry) + : (Object.prototype.hasOwnProperty.call(entry, 'answer') + ? entry.answer + : (Object.prototype.hasOwnProperty.call(entry, 'value') ? entry.value : clonePlainObject(entry))); + }); + return normalized; } - } - - ExamData.MetaRepository = MetaRepository; -})(window); - - -/* ===== js/data/index.js ===== */ -(function(window) { - const ExamData = window.ExamData = window.ExamData || {}; - - function createDefaultUserStats() { - const now = new Date().toISOString(); - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - lastPracticeDate: null, - achievements: [], - createdAt: now, - updatedAt: now - }; - } - - function createDefaultVocabConfig() { - return { - dailyNew: 20, - reviewLimit: 100, - masteryCount: 4, - theme: 'auto', - notify: true - }; - } - - function createMetaFacade(metaRepo) { - function assertAllowedKey(key) { - if (key === 'user_stats') { - throw new Error('user_stats must go through PracticeRecordAPI'); - } - } - - return Object.freeze({ - async get(key, defaultValue, options = {}) { - assertAllowedKey(key); - return await metaRepo.get(key, defaultValue, options); - }, - async set(key, value, options = {}) { - assertAllowedKey(key); - return await metaRepo.set(key, value, options); - }, - async remove(key, options = {}) { - assertAllowedKey(key); - return await metaRepo.remove(key, options); - }, - async runConsistencyCheck(keys) { - const targetKeys = Array.isArray(keys) - ? keys.filter((key) => key !== 'user_stats') - : undefined; - return await metaRepo.runConsistencyCheck(targetKeys); + if (!rawMap || typeof rawMap !== 'object') { + return normalized; + } + Object.entries(rawMap).forEach(([key, value], index) => { + const normalizedKey = normalizeReplayQuestionKey(key, index); + if (normalizedKey) { + normalized[normalizedKey] = value; } }); + return normalized; } - function bootstrap() { - if (!window.persistentStore) { - console.warn('[data/index] StorageManager 未就绪,延迟初始化数据仓库'); - setTimeout(bootstrap, 100); - return; - } - - if (window.dataRepositories) { - return; - } - - if (!window.PracticeCore || typeof window.PracticeCore.__installInternalRepositories !== 'function') { - console.warn('[data/index] PracticeCore internal installer 未就绪,延迟初始化数据仓库'); - setTimeout(bootstrap, 100); - return; - } + function normalizeAnswerMap(rawAnswers = {}) { + const map = {}; - let createInternalOptions = null; - if (typeof window.__installStorageInternalAccess === 'function') { - window.__installStorageInternalAccess((factory) => { - createInternalOptions = typeof factory === 'function' ? factory : null; - return Boolean(createInternalOptions); + if (Array.isArray(rawAnswers)) { + rawAnswers.forEach((entry, index) => { + if (!entry) return; + const key = normalizeQuestionKey(entry.questionId, index); + const rawValue = entry.answer ?? entry.userAnswer ?? entry.value ?? entry; + map[key] = normalizeAnswerValue(rawValue); }); + return map; } - if (!createInternalOptions) { - console.warn('[data/index] Storage internal access 未就绪,延迟初始化数据仓库'); - setTimeout(bootstrap, 100); - return; + + if (!rawAnswers || typeof rawAnswers !== 'object') { + return map; } - const dataSource = new ExamData.StorageDataSource(window.persistentStore, { - createInternalOptions - }); - const registry = new ExamData.DataRepositoryRegistry(dataSource); - - const practiceRepo = new ExamData.PracticeRepository(dataSource, { maxRecords: 1000 }); - const settingsRepo = new ExamData.SettingsRepository(dataSource); - const backupRepo = new ExamData.BackupRepository(dataSource, { maxBackups: 20 }); - const metaRepo = new ExamData.MetaRepository(dataSource, { - user_stats: { - defaultValue: createDefaultUserStats, - validators: [ - (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_stats 必须为对象' - ] - }, - storage_version: { - defaultValue: () => null, - validators: [ - (value) => value === null || typeof value === 'string' || 'storage_version 必须是字符串或 null' - ], - cloneOnRead: false - }, - data_restored: { - defaultValue: () => false, - validators: [ - (value) => typeof value === 'boolean' || 'data_restored 必须是布尔值' - ], - cloneOnRead: false - }, - active_sessions: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'active_sessions 必须为数组' - ] - }, - temp_practice_records: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'temp_practice_records 必须为数组' - ] - }, - interrupted_records: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'interrupted_records 必须为数组' - ] - }, - exam_index: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'exam_index 必须为数组' - ] - }, - vocab_words: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'vocab_words 必须为数组' - ] - }, - vocab_user_config: { - defaultValue: createDefaultVocabConfig, - validators: [ - (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'vocab_user_config 必须为对象' - ] - }, - vocab_review_queue: { - defaultValue: () => [], - validators: [ - (value) => Array.isArray(value) || 'vocab_review_queue 必须为数组' - ] - }, - vocab_list_reading_highlights: { - defaultValue: () => [], - validators: [ - (value) => ( - Array.isArray(value) - || (value && typeof value === 'object' && Array.isArray(value.words)) - ) || 'vocab_list_reading_highlights 必须为数组或词表对象' - ] - }, - legacy_practice_records_migrated: { - defaultValue: () => false, - validators: [ - (value) => typeof value === 'boolean' || 'legacy_practice_records_migrated 必须为布尔值' - ], - cloneOnRead: false + Object.entries(rawAnswers).forEach(([rawKey, rawValue], index) => { + if (isNoiseKey(rawKey)) { + return; } + const key = normalizeQuestionKey(rawKey, index); + const resolvedValue = rawValue && typeof rawValue === 'object' && 'answer' in rawValue + ? rawValue.answer + : rawValue; + map[key] = normalizeAnswerValue(resolvedValue); }); - registry.register('practice', practiceRepo); - registry.register('settings', settingsRepo); - registry.register('backups', backupRepo); - registry.register('meta', metaRepo); - - const internalApi = { - get practice() { return practiceRepo; }, - get settings() { return settingsRepo; }, - get backups() { return backupRepo; }, - get meta() { return metaRepo; }, - transaction(names, handler) { - return registry.transaction(names, handler); - }, - runConsistencyChecks(names) { - return registry.runConsistencyChecks(names); - } - }; - window.PracticeCore.__installInternalRepositories(internalApi, { createInternalOptions }); - if (window.__installStorageInternalAccess) { - try { - delete window.__installStorageInternalAccess; - } catch (_) { - window.__installStorageInternalAccess = undefined; - } - } - const metaFacade = createMetaFacade(metaRepo); - const api = { - get settings() { return settingsRepo; }, - get backups() { return backupRepo; }, - get meta() { return metaFacade; }, - transaction(names, handler) { - const targetNames = Array.isArray(names) ? names : []; - if (targetNames.includes('practice')) { - throw new Error('practice_records transactions must go through PracticeRecordAPI'); - } - return registry.transaction(names, handler); - }, - runConsistencyChecks(names) { - const targetNames = Array.isArray(names) - ? names.filter((name) => name !== 'practice') - : undefined; - return registry.runConsistencyChecks(targetNames); - } - }; - const registryApi = window.StorageProviderRegistry; - if (registryApi && typeof registryApi.registerStorageProviders === 'function') { - registryApi.registerStorageProviders({ - repositories: api, - storageManager: window.storage || null, - persistentStore: window.persistentStore || null, - preferenceStore: window.preferenceStore || null - }); - } else { - window.dataRepositories = api; - } - - ExamData.registry = registry; - ExamData.createDefaultUserStats = createDefaultUserStats; - ExamData.createDefaultVocabConfig = createDefaultVocabConfig; - console.log('[data/index] 数据仓库初始化完成'); + return map; } - bootstrap(); -})(window); + function normalizeAnswerComparison(comparison) { + if (!comparison || typeof comparison !== 'object') { + return {}; + } + const sanitizer = global.AnswerSanitizer; + if (sanitizer && typeof sanitizer.sanitizeComparisonMap === 'function') { + return sanitizer.sanitizeComparisonMap(comparison); + } -/* ===== js/core/practiceCore.js ===== */ -(function initPracticeCore(global) { - 'use strict'; + const normalized = {}; + Object.entries(comparison).forEach(([questionId, entry]) => { + if (isNoiseKey(questionId) || !entry || typeof entry !== 'object') { + return; + } + const userAnswer = normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer); + const correctAnswer = normalizeAnswerValue(entry.correctAnswer ?? entry.correct); + if (!userAnswer && !correctAnswer) { + return; + } + normalized[questionId] = { + questionId: entry.questionId || questionId, + userAnswer, + correctAnswer, + isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null + }; + }); - if (global.PracticeCore && global.PracticeCore.__stable === true) { - return; + return normalized; } - const MESSAGE_TYPE_ALIASES = Object.freeze({ - practice_complete: 'PRACTICE_COMPLETE', - practice_completed: 'PRACTICE_COMPLETE', - PracticeComplete: 'PRACTICE_COMPLETE', - SESSION_COMPLETE: 'PRACTICE_COMPLETE', - session_complete: 'PRACTICE_COMPLETE', - session_completed: 'PRACTICE_COMPLETE', - EXAM_FINISHED: 'PRACTICE_COMPLETE', - QUIZ_COMPLETE: 'PRACTICE_COMPLETE', - QUIZ_COMPLETED: 'PRACTICE_COMPLETE', - TEST_COMPLETE: 'PRACTICE_COMPLETE', - LESSON_COMPLETE: 'PRACTICE_COMPLETE', - WORKOUT_COMPLETE: 'PRACTICE_COMPLETE', - SESSION_READY: 'SESSION_READY', - session_ready: 'SESSION_READY', - EXAM_COMPLETED: 'exam_completed', - EXAM_PROGRESS: 'exam_progress', - EXAM_ERROR: 'exam_error', - progress_update: 'PROGRESS_UPDATE', - SESSION_PROGRESS: 'PROGRESS_UPDATE', - session_progress: 'PROGRESS_UPDATE', - practice_progress: 'PROGRESS_UPDATE', - SESSION_ERROR: 'ERROR_OCCURRED', - session_error: 'ERROR_OCCURRED', - practice_error: 'ERROR_OCCURRED', - REQUEST_INIT: 'REQUEST_INIT', - request_init: 'REQUEST_INIT', - REQUEST_SESSION_INIT: 'REQUEST_INIT', - INIT_SESSION: 'INIT_SESSION', - init_session: 'INIT_SESSION' - }); - - const PRACTICE_COMPLETE_TYPES = new Set([ - 'PRACTICE_COMPLETE', - 'PRACTICE_COMPLETED', - 'SESSION_COMPLETE', - 'SESSION_COMPLETED', - 'EXAM_FINISHED', - 'QUIZ_COMPLETE', - 'QUIZ_COMPLETED', - 'TEST_COMPLETE', - 'LESSON_COMPLETE', - 'WORKOUT_COMPLETE' - ]); - - const STORAGE_KEYS = Object.freeze({ - practiceRecords: 'practice_records', - userStats: 'user_stats', - activeSessions: 'active_sessions', - tempPracticeRecords: 'temp_practice_records' - }); - let internalRepositories = null; - // 由 data/index.js 在仓库注入时通过 __installInternalRepositories 第二参数传入, - // 使仓库未注入前的 fallback 路径也能拿到 storage internal token,避免被新保护层拒绝。 - let internalStorageAccess = null; - - function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); + function convertComparisonToMap(comparison, key = 'correctAnswer') { + if (!comparison || typeof comparison !== 'object') { + return {}; + } + const map = {}; + Object.entries(comparison).forEach(([questionId, entry]) => { + if (!entry || typeof entry !== 'object') return; + const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.userAnswer ?? entry.user); + if (value != null && String(value).trim() !== '') { + map[questionId] = value; + } + }); + return map; } - function safeParseJson(value) { - if (typeof value !== 'string') { - return null; - } - try { - return JSON.parse(value); - } catch (_) { + function convertComparisonToDetails(comparison) { + if (!comparison || typeof comparison !== 'object') { return null; } + const details = {}; + Object.entries(comparison).forEach(([questionId, entry]) => { + if (!entry || typeof entry !== 'object') return; + details[questionId] = { + userAnswer: normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer), + correctAnswer: normalizeAnswerValue(entry.correctAnswer ?? entry.correct), + isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null + }; + }); + return details; } - function clonePlainObject(value) { - if (value == null || typeof value !== 'object') { - return value ?? null; - } - if (Array.isArray(value)) { - return value.map((item) => clonePlainObject(item)).filter((item) => item !== undefined); - } - const clone = {}; - Object.keys(value).forEach((key) => { - clone[key] = clonePlainObject(value[key]); + function buildAnswerDetails(answerMap = {}, correctMap = {}) { + const details = {}; + const keys = new Set([ + ...Object.keys(answerMap || {}), + ...Object.keys(correctMap || {}) + ]); + + keys.forEach((questionId) => { + const userAnswer = normalizeAnswerValue(answerMap[questionId]); + const correctAnswer = normalizeAnswerValue(correctMap[questionId]); + let isCorrect = null; + if (correctAnswer) { + const matchCore = global.AnswerMatchCore; + isCorrect = matchCore && typeof matchCore.compareAnswers === 'function' + ? matchCore.compareAnswers(userAnswer, correctAnswer) === true + : userAnswer.toLowerCase() === correctAnswer.toLowerCase(); + } + details[questionId] = { + userAnswer: userAnswer || '-', + correctAnswer: correctAnswer || '-', + isCorrect + }; }); - return clone; - } - function ensureNumber(value, fallback = 0) { - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : fallback; + return details; } - function normalizeDateCandidate(value) { - if (!value) { - return null; - } - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString(); + function compareAnswerValues(userAnswer, correctAnswer) { + if (userAnswer == null || correctAnswer == null) { + return false; } - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (/^\d+$/.test(trimmed)) { - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - return new Date(trimmed.length > 10 ? numeric : numeric * 1000).toISOString(); - } - } - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return null; - } - - function firstDateCandidate() { - for (let index = 0; index < arguments.length; index += 1) { - const normalized = normalizeDateCandidate(arguments[index]); - if (normalized) { - return normalized; - } - } - return null; - } - - function firstStringCandidate() { - for (let index = 0; index < arguments.length; index += 1) { - const value = arguments[index]; - if (value === undefined || value === null) { - continue; - } - const trimmed = String(value).trim(); - if (trimmed) { - return trimmed; - } - } - return null; - } - - function resolveDurationSeconds(recordData = {}, startTime = null, endTime = null) { - const realData = isPlainObject(recordData.realData) ? recordData.realData : {}; - const scoreInfo = isPlainObject(recordData.scoreInfo) - ? recordData.scoreInfo - : (isPlainObject(realData.scoreInfo) ? realData.scoreInfo : {}); - const candidates = [ - recordData.duration, - realData.duration, - recordData.durationSeconds, - recordData.duration_seconds, - recordData.elapsedSeconds, - recordData.elapsed_seconds, - recordData.timeSpent, - recordData.time_spent, - realData.durationSeconds, - realData.elapsedSeconds, - realData.timeSpent, - scoreInfo.duration, - scoreInfo.timeSpent - ]; - - for (let index = 0; index < candidates.length; index += 1) { - const numeric = Number(candidates[index]); - if (Number.isFinite(numeric) && numeric > 0) { - return numeric; - } - } - - const start = startTime ? new Date(startTime).getTime() : NaN; - const end = endTime ? new Date(endTime).getTime() : NaN; - if (Number.isFinite(start) && Number.isFinite(end) && end > start) { - return Math.round((end - start) / 1000); - } - - if (Array.isArray(realData.interactions) && realData.interactions.length) { - const timestamps = realData.interactions - .map(item => item && Number(item.timestamp)) - .filter(value => Number.isFinite(value)); - if (timestamps.length) { - const span = Math.max(...timestamps) - Math.min(...timestamps); - if (Number.isFinite(span) && span > 0) { - return Math.floor(span / 1000); - } - } - } - - for (let index = 0; index < candidates.length; index += 1) { - const numeric = Number(candidates[index]); - if (Number.isFinite(numeric) && numeric >= 0) { - return numeric; - } - } - - return 0; - } - - function normalizePracticeType(rawType) { - if (!rawType) return null; - const normalized = String(rawType).toLowerCase(); - if (normalized.includes('listen')) return 'listening'; - if (normalized.includes('read')) return 'reading'; - return null; - } - - function resolveRecordDate(recordData = {}, now = new Date().toISOString()) { - const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {}; - const candidates = [ - metadata.date, - recordData.date, - recordData.endTime, - recordData.end_time, - recordData.completedAt, - recordData.finishedAt, - recordData.finishTime, - recordData.startTime, - recordData.start_time, - recordData.startedAt, - recordData.createdAt, - recordData.timestamp, - now - ]; - - for (let i = 0; i < candidates.length; i += 1) { - const normalized = normalizeDateCandidate(candidates[i]); - if (normalized) { - return normalized; - } - } - - return now; - } - - function inferExamId(recordData = {}) { - if (!recordData || typeof recordData !== 'object') { - return null; - } - - const metadata = isPlainObject(recordData.metadata) ? recordData.metadata : {}; - const direct = firstStringCandidate( - recordData.examId, - recordData.exam_id, - recordData.examID, - metadata.examId, - metadata.exam_id - ); - if (direct) { - return direct; - } - if (Array.isArray(recordData.suiteEntries)) { - const suiteExam = recordData.suiteEntries.find((entry) => entry && entry.examId); - if (suiteExam) { - return suiteExam.examId; - } - } - if (typeof recordData.id === 'string') { - const match = recordData.id.match(/^record_([^_]+)_/); - if (match && match[1]) { - return match[1]; - } - } - - return null; - } - - function normalizeAnswerValue(value) { - const sanitizer = global.AnswerSanitizer; - if (sanitizer && typeof sanitizer.normalizeValue === 'function') { - return sanitizer.normalizeValue(value); - } - - if (value === undefined || value === null) { - return ''; - } - if (typeof value === 'string') { - const trimmed = value.trim(); - return /^\[object\s/i.test(trimmed) ? '' : trimmed; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value).trim(); - } - if (Array.isArray(value)) { - return value.map((item) => normalizeAnswerValue(item)).filter(Boolean).join(','); - } - if (typeof value === 'object') { - const preferKeys = ['value', 'label', 'text', 'answer', 'content', 'userAnswer', 'correctAnswer']; - for (let i = 0; i < preferKeys.length; i += 1) { - const entry = value[preferKeys[i]]; - if (typeof entry === 'string') { - const trimmed = entry.trim(); - if (trimmed && !/^\[object\s/i.test(trimmed)) { - return trimmed; - } - } - } - if (typeof value.innerText === 'string') { - const text = value.innerText.trim(); - if (text && !/^\[object\s/i.test(text)) { - return text; - } - } - if (typeof value.textContent === 'string') { - const text = value.textContent.trim(); - if (text && !/^\[object\s/i.test(text)) { - return text; - } - } - return ''; - } - - return String(value).trim(); - } - - function isNoiseKey(key) { - if (!key) return true; - - const keyStr = String(key).toLowerCase(); - const noiseKeys = [ - 'playback-speed', 'playbackspeed', 'volume-slider', 'volumeslider', - 'audio-volume', 'audiocurrenttime', 'audio-duration', 'audioduration', - 'settings', 'lastfocuselement', 'sessionid', 'examid', - 'nextexamid', 'previousexamid', 'folder', 'source', 'result', - 'metadata', 'practicesettings', 'config', 'state' - ]; - if (noiseKeys.includes(keyStr)) { - return true; - } - - const noisePatterns = [ - /playback/i, /volume/i, /slider/i, /speed/i, - /audio/i, /duration/i, /config/i, /setting/i - ]; - for (let i = 0; i < noisePatterns.length; i += 1) { - if (noisePatterns[i].test(keyStr)) { - return true; - } - } - - const questionMatch = keyStr.match(/q?(\d+)/); - if (questionMatch) { - const number = parseInt(questionMatch[1], 10); - if (number < 1 || number > 200) { - return true; - } - } - - return false; - } - - function normalizeQuestionKey(rawKey, index) { - if (rawKey == null || rawKey === '') { - return `q${index + 1}`; - } - const key = String(rawKey).trim(); - return key.startsWith('q') ? key : `q${key}`; - } - - function normalizeReplayQuestionKey(rawKey, index) { - if (rawKey == null || rawKey === '') { - return Number.isInteger(index) ? `q${index + 1}` : ''; - } - const raw = String(rawKey).trim(); - if (!raw) { - return Number.isInteger(index) ? `q${index + 1}` : ''; - } - const splitIndex = raw.lastIndexOf('::'); - const value = splitIndex >= 0 ? raw.slice(splitIndex + 2).trim() : raw; - if (!value) { - return Number.isInteger(index) ? `q${index + 1}` : ''; - } - const explicitQuestion = value.match(/^q\s*[-_ ]?(\d+)$/i) || value.match(/\bq\s*[-_ ]?(\d+)\b/i); - if (explicitQuestion) { - return `q${explicitQuestion[1]}`; - } - if (/^\d+$/.test(value)) { - return `q${value}`; - } - const trailingNumber = value.match(/(\d+)(?!.*\d)/); - if (trailingNumber) { - return `q${trailingNumber[1]}`; - } - return value.toLowerCase(); - } - - function normalizeReplayMap(rawMap = {}) { - const normalized = {}; - if (Array.isArray(rawMap)) { - rawMap.forEach((entry, index) => { - if (entry == null) { - return; - } - if (typeof entry !== 'object') { - normalized[`q${index + 1}`] = entry; - return; - } - const normalizedKey = normalizeReplayQuestionKey( - entry.questionId ?? entry.question ?? entry.id, - index - ); - if (!normalizedKey) { - return; - } - const hasAnswerValue = Object.prototype.hasOwnProperty.call(entry, 'answer') - || Object.prototype.hasOwnProperty.call(entry, 'value'); - const isComparisonEntry = !hasAnswerValue && ( - Object.prototype.hasOwnProperty.call(entry, 'userAnswer') - || Object.prototype.hasOwnProperty.call(entry, 'correctAnswer') - || Object.prototype.hasOwnProperty.call(entry, 'isCorrect') - ); - normalized[normalizedKey] = isComparisonEntry - ? clonePlainObject(entry) - : (Object.prototype.hasOwnProperty.call(entry, 'answer') - ? entry.answer - : (Object.prototype.hasOwnProperty.call(entry, 'value') ? entry.value : clonePlainObject(entry))); - }); - return normalized; - } - if (!rawMap || typeof rawMap !== 'object') { - return normalized; - } - Object.entries(rawMap).forEach(([key, value], index) => { - const normalizedKey = normalizeReplayQuestionKey(key, index); - if (normalizedKey) { - normalized[normalizedKey] = value; - } - }); - return normalized; - } - - function normalizeAnswerMap(rawAnswers = {}) { - const map = {}; - - if (Array.isArray(rawAnswers)) { - rawAnswers.forEach((entry, index) => { - if (!entry) return; - const key = normalizeQuestionKey(entry.questionId, index); - const rawValue = entry.answer ?? entry.userAnswer ?? entry.value ?? entry; - map[key] = normalizeAnswerValue(rawValue); - }); - return map; - } - - if (!rawAnswers || typeof rawAnswers !== 'object') { - return map; - } - - Object.entries(rawAnswers).forEach(([rawKey, rawValue], index) => { - if (isNoiseKey(rawKey)) { - return; - } - const key = normalizeQuestionKey(rawKey, index); - const resolvedValue = rawValue && typeof rawValue === 'object' && 'answer' in rawValue - ? rawValue.answer - : rawValue; - map[key] = normalizeAnswerValue(resolvedValue); - }); - - return map; - } - - function normalizeAnswerComparison(comparison) { - if (!comparison || typeof comparison !== 'object') { - return {}; - } - - const sanitizer = global.AnswerSanitizer; - if (sanitizer && typeof sanitizer.sanitizeComparisonMap === 'function') { - return sanitizer.sanitizeComparisonMap(comparison); - } - - const normalized = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (isNoiseKey(questionId) || !entry || typeof entry !== 'object') { - return; - } - const userAnswer = normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer); - const correctAnswer = normalizeAnswerValue(entry.correctAnswer ?? entry.correct); - if (!userAnswer && !correctAnswer) { - return; - } - normalized[questionId] = { - questionId: entry.questionId || questionId, - userAnswer, - correctAnswer, - isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null - }; - }); - - return normalized; - } - - function convertComparisonToMap(comparison, key = 'correctAnswer') { - if (!comparison || typeof comparison !== 'object') { - return {}; - } - const map = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (!entry || typeof entry !== 'object') return; - const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.userAnswer ?? entry.user); - if (value != null && String(value).trim() !== '') { - map[questionId] = value; - } - }); - return map; - } - - function convertComparisonToDetails(comparison) { - if (!comparison || typeof comparison !== 'object') { - return null; - } - const details = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (!entry || typeof entry !== 'object') return; - details[questionId] = { - userAnswer: normalizeAnswerValue(entry.userAnswer ?? entry.user ?? entry.answer), - correctAnswer: normalizeAnswerValue(entry.correctAnswer ?? entry.correct), - isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null - }; - }); - return details; - } - - function buildAnswerDetails(answerMap = {}, correctMap = {}) { - const details = {}; - const keys = new Set([ - ...Object.keys(answerMap || {}), - ...Object.keys(correctMap || {}) - ]); - - keys.forEach((questionId) => { - const userAnswer = normalizeAnswerValue(answerMap[questionId]); - const correctAnswer = normalizeAnswerValue(correctMap[questionId]); - let isCorrect = null; - if (correctAnswer) { - const matchCore = global.AnswerMatchCore; - isCorrect = matchCore && typeof matchCore.compareAnswers === 'function' - ? matchCore.compareAnswers(userAnswer, correctAnswer) === true - : userAnswer.toLowerCase() === correctAnswer.toLowerCase(); - } - details[questionId] = { - userAnswer: userAnswer || '-', - correctAnswer: correctAnswer || '-', - isCorrect - }; - }); - - return details; - } - - function compareAnswerValues(userAnswer, correctAnswer) { - if (userAnswer == null || correctAnswer == null) { - return false; - } - const matchCore = global.AnswerMatchCore; - if (matchCore && typeof matchCore.compareAnswers === 'function') { - return matchCore.compareAnswers(userAnswer, correctAnswer) === true; - } - return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase(); - } - - function mergeReplayMapFirstWins() { - const merged = {}; - Array.prototype.slice.call(arguments).forEach((source) => { - if (!source || typeof source !== 'object' || Array.isArray(source)) { - return; - } - const normalized = normalizeReplayMap(source); - Object.entries(normalized).forEach(([key, value]) => { - if (!Object.prototype.hasOwnProperty.call(merged, key)) { - merged[key] = value; - } - }); - }); - return merged; - } - - function buildReplayCorrectAnswerMap(entry = {}) { - const realData = isPlainObject(entry.realData) ? entry.realData : {}; - const rawData = isPlainObject(entry.rawData) ? entry.rawData : {}; - const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; - return mergeReplayMapFirstWins( - entry.correctAnswerMap, - realData.correctAnswerMap, - rawData.correctAnswerMap, - rawRealData.correctAnswerMap - ); - } - - function buildReplayResultSnapshot(entry = {}) { - const realData = isPlainObject(entry.realData) ? entry.realData : {}; - const rawData = isPlainObject(entry.rawData) ? entry.rawData : {}; - const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; - const answers = mergeReplayMapFirstWins( - entry.answers, - realData.answers, - rawData.answers, - rawRealData.answers - ); - const correctAnswerMap = buildReplayCorrectAnswerMap(entry); - const rawComparison = mergeReplayMapFirstWins( - entry.answerComparison, - realData.answerComparison, - rawData.answerComparison, - rawRealData.answerComparison - ); - const questionIds = new Set([ - ...Object.keys(answers), - ...Object.keys(correctAnswerMap), - ...Object.keys(rawComparison), - ...(Array.isArray(entry.allQuestionIds) - ? entry.allQuestionIds.map((item, index) => normalizeReplayQuestionKey(item, index)).filter(Boolean) - : []) - ]); - - let correctCount = 0; - const answerComparison = {}; - questionIds.forEach((questionId) => { - const rawEntry = rawComparison[questionId]; - const comparisonEntry = isPlainObject(rawEntry) ? rawEntry : {}; - const userAnswer = Object.prototype.hasOwnProperty.call(comparisonEntry, 'userAnswer') - ? comparisonEntry.userAnswer - : (Object.prototype.hasOwnProperty.call(answers, questionId) ? answers[questionId] : ''); - const hasCanonicalCorrectAnswer = Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId); - const correctAnswer = hasCanonicalCorrectAnswer ? correctAnswerMap[questionId] : ''; - const isCorrect = hasCanonicalCorrectAnswer - ? compareAnswerValues(userAnswer, correctAnswer) - : null; - if (isCorrect) { - correctCount += 1; - } - answerComparison[questionId] = { - questionId, - userAnswer, - correctAnswer, - isCorrect - }; - }); - - const totalQuestions = questionIds.size; - const sourceScoreInfo = isPlainObject(entry.scoreInfo) - ? entry.scoreInfo - : (isPlainObject(realData.scoreInfo) - ? realData.scoreInfo - : (isPlainObject(rawData.scoreInfo) ? rawData.scoreInfo : {})); - const scoreInfo = clonePlainObject(sourceScoreInfo) || {}; - const hasCompleteCanonicalCorrectAnswers = totalQuestions > 0 - && Array.from(questionIds).every(questionId => Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId)); - scoreInfo.correct = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.correct)) - ? correctCount - : Number(scoreInfo.correct); - scoreInfo.total = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.total)) - ? totalQuestions - : Number(scoreInfo.total); - scoreInfo.totalQuestions = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.totalQuestions)) - ? scoreInfo.total - : Number(scoreInfo.totalQuestions); - const existingAccuracy = Number(scoreInfo.accuracy); - scoreInfo.accuracy = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(existingAccuracy) - ? (scoreInfo.totalQuestions > 0 ? scoreInfo.correct / scoreInfo.totalQuestions : 0) - : existingAccuracy; - scoreInfo.percentage = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.percentage)) - ? Math.round(scoreInfo.accuracy * 100) - : Number(scoreInfo.percentage); - scoreInfo.answerKeyComplete = hasCompleteCanonicalCorrectAnswers; - - return { - answers, - correctAnswers: correctAnswerMap, - correctAnswerMap, - answerComparison, - scoreInfo - }; - } - - function deriveCorrectMapFromDetails(details) { - if (!details || typeof details !== 'object') { - return {}; - } - const map = {}; - Object.entries(details).forEach(([questionId, info]) => { - if (!info) return; - const correctAnswer = info.correctAnswer || info.answer || info.value; - if (correctAnswer != null) { - map[questionId] = normalizeAnswerValue(correctAnswer); - } - }); - return map; - } - - function buildAnswerArray(answers, correctMap = {}) { - if (Array.isArray(answers)) { - return answers.map((answer, index) => { - const questionId = answer.questionId || `q${index + 1}`; - const userAnswer = normalizeAnswerValue(answer.answer); - const normalizedCorrect = normalizeAnswerValue(answer.correctAnswer ?? correctMap[questionId]); - return { - questionId, - answer: userAnswer, - correctAnswer: normalizedCorrect, - correct: normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : Boolean(answer.correct), - timeSpent: ensureNumber(answer.timeSpent, 0), - questionType: answer.questionType || 'unknown', - timestamp: answer.timestamp || new Date().toISOString() - }; - }); - } - - const answerMap = normalizeAnswerMap(answers); - const keys = new Set([ - ...Object.keys(answerMap), - ...Object.keys(correctMap || {}) - ]); - - const list = []; - keys.forEach((questionId, index) => { - const userAnswer = normalizeAnswerValue(answerMap[questionId]); - const normalizedCorrect = normalizeAnswerValue(correctMap[questionId]); - const isCorrect = normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : false; - list.push({ - questionId: questionId || `q${index + 1}`, - answer: userAnswer, - correctAnswer: normalizedCorrect, - correct: isCorrect, - timeSpent: 0, - questionType: 'unknown', - timestamp: new Date().toISOString() - }); - }); - return list; - } - - function deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) { - const candidates = [ - recordData.totalQuestions, - recordData.questionCount, - recordData.question_count, - typeof recordData.questions === 'number' ? recordData.questions : null, - recordData.scoreInfo && recordData.scoreInfo.total, - recordData.scoreInfo && recordData.scoreInfo.totalQuestions, - recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.totalQuestions, - recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.total, - recordData.realData && recordData.realData.totalQuestions, - recordData.realData && recordData.realData.questionCount - ]; - for (let i = 0; i < candidates.length; i += 1) { - const numeric = Number(candidates[i]); - if (Number.isFinite(numeric) && numeric >= 0) { - return numeric; - } - } - - if (Array.isArray(recordData.answers)) { - return recordData.answers.length; - } - if (Array.isArray(recordData.answerList)) { - return recordData.answerList.length; - } - const detailSources = [ - recordData.answerDetails, - recordData.scoreInfo && recordData.scoreInfo.details, - recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details - ]; - for (let i = 0; i < detailSources.length; i += 1) { - const details = detailSources[i]; - if (details && typeof details === 'object') { - return Object.keys(details).length; - } - } - - return fallbackLength || 0; - } - - function deriveCorrectAnswerCount(recordData = {}, answers = []) { - const numericCandidates = [ - recordData.correctAnswers, - recordData.correctAnswersCount, - recordData.correctCount, - recordData.correct, - recordData.score, - recordData.scoreInfo && recordData.scoreInfo.correct, - recordData.scoreInfo && recordData.scoreInfo.score, - recordData.realData && recordData.realData.correctAnswersCount, - recordData.realData && recordData.realData.correctCount, - recordData.realData && recordData.realData.correct, - recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.correct, - recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.score - ]; - for (let i = 0; i < numericCandidates.length; i += 1) { - const numeric = Number(numericCandidates[i]); - if (Number.isFinite(numeric) && numeric >= 0) { - return numeric; - } - } - - if (Array.isArray(answers) && answers.length > 0) { - return answers.reduce((sum, answer) => { - if (!answer || typeof answer !== 'object') { - return sum; - } - return (answer.correct === true || answer.isCorrect === true) ? sum + 1 : sum; - }, 0); - } - - const detailSources = [ - recordData.answerDetails, - recordData.scoreInfo && recordData.scoreInfo.details, - recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details - ]; - for (let i = 0; i < detailSources.length; i += 1) { - const details = detailSources[i]; - if (!details || typeof details !== 'object') { - continue; - } - let hasFlag = false; - let correctCount = 0; - Object.values(details).forEach((detail) => { - if (!detail || typeof detail !== 'object') { - return; - } - if (detail.isCorrect === true || detail.correct === true) { - correctCount += 1; - } - hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean'; - }); - if (hasFlag) { - return correctCount; - } - } - - return 0; - } - - function buildMetadata(recordData = {}, type) { - const metadata = Object.assign({}, recordData.metadata || {}); - const examId = recordData.examId; - const fallbackTitle = recordData.title || recordData.examTitle || recordData.examName || recordData.name || examId || 'Unknown Exam'; - const fallbackCategory = recordData.category || recordData.examCategory || recordData.section || recordData.mode || metadata.category || 'Unknown'; - const fallbackFrequency = recordData.frequency || metadata.frequency || 'unknown'; - - metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle; - metadata.category = metadata.category || fallbackCategory; - metadata.frequency = metadata.frequency || fallbackFrequency; - metadata.type = type; - metadata.examType = metadata.examType || type; - if (recordData.suiteSessionId && !metadata.suiteSessionId) { - metadata.suiteSessionId = recordData.suiteSessionId; - } - if (recordData.practiceMode && !metadata.practiceMode) { - metadata.practiceMode = recordData.practiceMode; - } - return metadata; - } - - function inferPracticeType(recordData = {}) { - const metadata = recordData.metadata || {}; - const normalized = normalizePracticeType( - recordData.type - || metadata.type - || metadata.examType - || recordData.category - || recordData.mode - || recordData.section - || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null) - ); - return normalized || 'reading'; - } - - function standardizeSuiteEntries(entries) { - if (!Array.isArray(entries)) { - return []; - } - return entries.map((entry, index) => { - if (!entry || typeof entry !== 'object') { - return null; - } - const answerComparisonSource = entry.answerComparison - || (entry.realData && entry.realData.answerComparison) - || (entry.rawData && entry.rawData.answerComparison) - || (entry.scoreInfo && entry.scoreInfo.details) - || null; - const entryCorrectMap = resolveRecordCorrectAnswerMap(entry, { comparison: answerComparisonSource }); - const normalizedAnswers = buildAnswerArray(entry.answers || entry.answerList || [], entryCorrectMap); - const answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - const highlights = Array.isArray(entry.highlights) - ? entry.highlights.slice() - : (Array.isArray(entry.rawData && entry.rawData.highlights) ? entry.rawData.highlights.slice() : []); - const scrollY = Number.isFinite(Number(entry.scrollY)) - ? Number(entry.scrollY) - : (Number.isFinite(Number(entry.rawData && entry.rawData.scrollY)) ? Number(entry.rawData.scrollY) : 0); - return { - examId: entry.examId || null, - title: entry.title || entry.examTitle || `套题第${index + 1}篇`, - category: entry.category || (entry.metadata && entry.metadata.category) || '套题', - duration: ensureNumber(entry.duration, 0), - scoreInfo: entry.scoreInfo ? clonePlainObject(entry.scoreInfo) : null, - answers: answerMap, - correctAnswerMap: entryCorrectMap, - answerComparison: clonePlainObject(answerComparisonSource) || null, - metadata: entry.metadata ? Object.assign({}, entry.metadata) : {}, - highlights, - scrollY, - rawData: entry.rawData ? clonePlainObject(entry.rawData) : null - }; - }).filter(Boolean); - } - - function mergeAnswerSources() { - const merged = {}; - Array.prototype.slice.call(arguments).forEach((source) => { - if (!source) { - return; - } - const normalized = normalizeAnswerMap(source); - Object.entries(normalized).forEach(([key, value]) => { - if (value == null) { - return; - } - const trimmed = String(value).trim(); - if (!trimmed) { - return; - } - if (!Object.prototype.hasOwnProperty.call(merged, key)) { - merged[key] = trimmed; - } - }); - }); - return merged; - } - - function resolveCorrectAnswerMap() { - const sources = Array.prototype.slice.call(arguments).filter((source) => isPlainObject(source)); - return mergeAnswerSources.apply(null, sources); - } - - function resolveRecordCorrectAnswerMap(recordData = {}, options = {}) { - if (!isPlainObject(recordData)) { - return {}; - } - const realData = isPlainObject(recordData.realData) ? recordData.realData : {}; - const rawData = isPlainObject(recordData.rawData) ? recordData.rawData : {}; - const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; - const comparisonSource = options.comparison - || recordData.answerComparison - || realData.answerComparison - || rawData.answerComparison - || rawRealData.answerComparison - || null; - return resolveCorrectAnswerMap( - ...(Array.isArray(options.prioritySources) ? options.prioritySources : []), - recordData.correctAnswerMap, - realData.correctAnswerMap, - rawData.correctAnswerMap, - rawRealData.correctAnswerMap, - recordData.correctAnswers, - realData.correctAnswers, - rawData.correctAnswers, - rawRealData.correctAnswers, - deriveCorrectMapFromDetails(recordData.answerDetails), - deriveCorrectMapFromDetails(recordData.scoreInfo && recordData.scoreInfo.details), - deriveCorrectMapFromDetails(realData.scoreInfo && realData.scoreInfo.details), - deriveCorrectMapFromDetails(rawData.scoreInfo && rawData.scoreInfo.details), - deriveCorrectMapFromDetails(rawRealData.scoreInfo && rawRealData.scoreInfo.details), - ...(Array.isArray(options.detailSources) - ? options.detailSources.map((details) => deriveCorrectMapFromDetails(details)) - : []), - convertComparisonToMap(comparisonSource, 'correctAnswer') - ); - } - - function defaultGenerateRecordId() { - return `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - } - - function standardizeRecord(recordData, options = {}) { - const now = new Date().toISOString(); - const type = inferPracticeType(recordData); - const recordDate = resolveRecordDate(recordData, now); - const resolvedExamId = inferExamId(recordData); - const recordId = firstStringCandidate( - recordData.id, - recordData.recordId, - recordData.record_id, - recordData.practiceId, - recordData.practice_id, - recordData.uuid - ); - const metadata = buildMetadata( - Object.assign({}, recordData, { examId: resolvedExamId }), - type - ); - const comparisonSource = recordData.answerComparison - || (recordData.realData && recordData.realData.answerComparison) - || null; - let normalizedCorrectMap = resolveRecordCorrectAnswerMap(recordData, { comparison: comparisonSource }); - - const normalizedAnswers = buildAnswerArray(recordData.answers || recordData.answerList || [], normalizedCorrectMap); - let answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) { - answerMap = convertComparisonToMap(comparisonSource, 'userAnswer'); - } - - const derivedTotalQuestions = deriveTotalQuestionCount(recordData, normalizedAnswers.length); - const derivedCorrectAnswers = deriveCorrectAnswerCount(recordData, normalizedAnswers); - const totalQuestions = ensureNumber(recordData.totalQuestions, derivedTotalQuestions); - const correctAnswers = ensureNumber(recordData.correctAnswers, derivedCorrectAnswers); - let accuracy = ensureNumber( - recordData.accuracy - ?? (recordData.realData && recordData.realData.accuracy) - ?? (recordData.scoreInfo && recordData.scoreInfo.accuracy) - ?? (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.accuracy) - ?? recordData.percentage - ?? (recordData.scoreInfo && recordData.scoreInfo.percentage), - totalQuestions > 0 ? correctAnswers / totalQuestions : 0 - ); - if (accuracy > 1 && accuracy <= 100) { - accuracy = accuracy / 100; - } - if (!Number.isFinite(accuracy) || accuracy < 0) { - accuracy = 0; - } else if (accuracy > 1) { - accuracy = 1; - } - - const detailSource = recordData.answerDetails - || (recordData.scoreInfo && recordData.scoreInfo.details) - || (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details) - || (comparisonSource ? convertComparisonToDetails(comparisonSource) : null) - || buildAnswerDetails(answerMap, normalizedCorrectMap); - - const startTime = firstDateCandidate( - recordData.startTime, - recordData.start_time, - recordData.startedAt, - recordData.createdAt, - recordData.timestamp, - recordData.date, - recordDate - ) || recordDate; - const endTime = firstDateCandidate( - recordData.endTime, - recordData.end_time, - recordData.completedAt, - recordData.finishedAt, - recordData.finishTime, - recordDate - ) || recordDate; - const resolvedTitle = recordData.title - || metadata.examTitle - || metadata.title - || recordData.examTitle - || recordData.examName - || recordData.name - || recordData.examId - || '未命名练习'; - const normalizedSuiteEntries = standardizeSuiteEntries(recordData.suiteEntries || []); - const normalizedComparison = comparisonSource && typeof comparisonSource === 'object' - ? clonePlainObject(comparisonSource) - : null; - const realDataCorrectAnswers = clonePlainObject(normalizedCorrectMap || {}); - const generateRecordId = typeof options.generateRecordId === 'function' - ? options.generateRecordId - : defaultGenerateRecordId; - - return { - id: recordId || generateRecordId(), - examId: resolvedExamId, - sessionId: recordData.sessionId || recordData.sessionID || null, - title: resolvedTitle, - type, - startTime, - endTime, - duration: resolveDurationSeconds(recordData, startTime, endTime), - date: recordDate, - status: recordData.status || 'completed', - score: ensureNumber(recordData.score ?? recordData.finalScore ?? (recordData.realData && recordData.realData.score), correctAnswers), - totalQuestions, - correctAnswers, - accuracy, - answers: normalizedAnswers, - answerDetails: detailSource || null, - correctAnswerMap: normalizedCorrectMap || {}, - questionTypePerformance: recordData.questionTypePerformance || {}, - metadata, - frequency: recordData.frequency || metadata.frequency || null, - suiteMode: Boolean(recordData.suiteMode || ((recordData.frequency || metadata.frequency || '').toLowerCase() === 'suite')), - suiteSessionId: recordData.suiteSessionId || (metadata && metadata.suiteSessionId) || null, - suiteEntries: normalizedSuiteEntries, - scoreInfo: recordData.scoreInfo - ? Object.assign({}, recordData.scoreInfo, { - details: recordData.scoreInfo.details || detailSource || null - }) - : (detailSource ? { details: detailSource } : null), - realData: recordData.realData - ? Object.assign({}, recordData.realData, { - answers: (recordData.realData && recordData.realData.answers) || answerMap, - correctAnswers: realDataCorrectAnswers, - correctAnswerMap: clonePlainObject(normalizedCorrectMap || {}), - scoreInfo: Object.assign({}, (recordData.realData && recordData.realData.scoreInfo) || {}, { - details: (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details) || detailSource || null - }), - answerComparison: (recordData.realData && recordData.realData.answerComparison) - ? clonePlainObject(recordData.realData.answerComparison) - : (normalizedComparison || null) - }) - : (normalizedComparison ? { answerComparison: normalizedComparison } : null), - answerComparison: normalizedComparison, - version: options.currentVersion || recordData.version || '0.6.2-fix', - createdAt: firstDateCandidate(recordData.createdAt, recordData.startTime, recordData.start_time, recordDate) || now, - updatedAt: firstDateCandidate(recordData.updatedAt, recordData.endTime, recordData.end_time, now) || now - }; - } - - function extractEnvelopeData(envelope) { - const candidates = [envelope.data, envelope.payload, envelope.detail]; - for (let i = 0; i < candidates.length; i += 1) { - const candidate = candidates[i]; - if (isPlainObject(candidate)) return candidate; - if (typeof candidate === 'string') { - const parsed = safeParseJson(candidate); - if (isPlainObject(parsed)) return parsed; - } - } - if (Array.isArray(envelope.args)) { - for (let i = 0; i < envelope.args.length; i += 1) { - const candidate = envelope.args[i]; - if (isPlainObject(candidate)) return candidate; - } - } - const fallback = {}; - const baseKeys = new Set(['type', 'messageType', 'action', 'event', 'data', 'payload', 'detail', 'args', 'source', 'message', 'messageData']); - let hasFallback = false; - Object.keys(envelope || {}).forEach((key) => { - if (!baseKeys.has(key)) { - fallback[key] = envelope[key]; - hasFallback = true; - } - }); - return hasFallback ? fallback : {}; - } - - function normalizeMessageType(value) { - if (typeof value !== 'string') { - return ''; - } - const normalized = value.trim(); - if (!normalized) { - return ''; - } - return MESSAGE_TYPE_ALIASES[normalized] || normalized.toUpperCase(); - } - - function normalizeMessage(rawEnvelope, depth = 0) { - if (depth > 2) { - return null; - } - - let envelope = rawEnvelope; - if (typeof envelope === 'string') { - envelope = safeParseJson(envelope); - } - if (!isPlainObject(envelope)) { - return null; - } - - const rawType = envelope.type || envelope.messageType || envelope.action || envelope.event || ''; - const type = normalizeMessageType(rawType); - - if (!type) { - const nested = envelope.message || envelope.messageData; - if (nested) { - return normalizeMessage(nested, depth + 1); - } - return null; - } - - const data = extractEnvelopeData(envelope); - const sourceTag = typeof envelope.source === 'string' - ? envelope.source - : (typeof data.source === 'string' ? data.source : ''); - - return { type, data: isPlainObject(data) ? data : {}, sourceTag, rawType: rawType || type }; - } - - function isPracticeCompleteType(type) { - if (!type) { - return false; - } - return PRACTICE_COMPLETE_TYPES.has(type) || normalizeMessageType(type) === 'PRACTICE_COMPLETE'; - } - - function buildEnvelope(type, data) { - return { - type, - data: isPlainObject(data) ? data : {} - }; - } - - function deriveCategory(recordPayload = {}, examEntry = null, metadata = {}) { - if (metadata.category) { - return metadata.category; - } - if (recordPayload.category) { - return recordPayload.category; - } - if (examEntry && examEntry.category) { - return examEntry.category; - } - if (recordPayload.pageType) { - return recordPayload.pageType; - } - if (recordPayload.url) { - const match = String(recordPayload.url).match(/\b(P[1-4])\b/i); - if (match) return match[1].toUpperCase(); - } - if (recordPayload.title) { - const match = String(recordPayload.title).match(/\b(P[1-4])\b/i); - if (match) return match[1].toUpperCase(); - } - return 'Unknown'; - } - - function deriveFrequency(recordPayload = {}, examEntry = null, metadata = {}) { - return recordPayload.frequency - || metadata.frequency - || (examEntry && examEntry.frequency) - || 'unknown'; - } - - function fromCompletion(payload, sessionContext = {}, examEntry = null, options = {}) { - const normalizedMessage = normalizeMessage(payload); - const rawPayload = normalizedMessage && isPracticeCompleteType(normalizedMessage.type) - ? normalizedMessage.data - : (isPlainObject(payload) ? payload : {}); - - if (!rawPayload || typeof rawPayload !== 'object') { - return null; - } - - const scoreInfo = Object.assign({}, rawPayload.scoreInfo || {}); - const metadata = Object.assign({}, sessionContext.metadata || {}, rawPayload.metadata || {}); - const resolvedExamId = rawPayload.examId - || sessionContext.examId - || metadata.examId - || (examEntry && examEntry.id) - || null; - const answerComparison = normalizeAnswerComparison( - rawPayload.answerComparison || (rawPayload.realData && rawPayload.realData.answerComparison) || null - ); - const answerMap = mergeAnswerSources( - rawPayload.answerMap, - rawPayload.answers, - rawPayload.realData && rawPayload.realData.answers, - sessionContext.answers, - convertComparisonToMap(answerComparison, 'userAnswer') - ); - const correctAnswerMap = mergeAnswerSources( - rawPayload.correctAnswerMap, - rawPayload.realData && rawPayload.realData.correctAnswerMap, - sessionContext.correctAnswerMap, - rawPayload.correctAnswers, - rawPayload.realData && rawPayload.realData.correctAnswers, - deriveCorrectMapFromDetails(scoreInfo.details), - deriveCorrectMapFromDetails(rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details), - convertComparisonToMap(answerComparison, 'correctAnswer') - ); - const answerDetails = rawPayload.answerDetails - || scoreInfo.details - || (rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details) - || buildAnswerDetails(answerMap, correctAnswerMap); - const answerList = buildAnswerArray(answerMap, correctAnswerMap); - const totalQuestions = ensureNumber( - rawPayload.totalQuestions ?? scoreInfo.total ?? scoreInfo.totalQuestions, - Object.keys(correctAnswerMap).length || Object.keys(answerMap).length - ); - const correctAnswers = ensureNumber( - rawPayload.correctAnswers ?? rawPayload.correctAnswersCount ?? scoreInfo.correct ?? scoreInfo.score ?? rawPayload.score, - deriveCorrectAnswerCount({ answerDetails, scoreInfo }, answerList) - ); - let accuracy = typeof rawPayload.accuracy === 'number' - ? rawPayload.accuracy - : (typeof scoreInfo.accuracy === 'number' - ? scoreInfo.accuracy - : (totalQuestions > 0 ? correctAnswers / totalQuestions : 0)); - if (accuracy > 1 && accuracy <= 100) { - accuracy = accuracy / 100; - } - const percentage = typeof scoreInfo.percentage === 'number' - ? scoreInfo.percentage - : Math.round(accuracy * 100); - const completedAt = resolveRecordDate({ - metadata, - date: rawPayload.date, - endTime: rawPayload.endTime, - completedAt: rawPayload.completedAt, - startTime: rawPayload.startTime, - timestamp: rawPayload.timestamp - }); - const duration = ensureNumber( - rawPayload.duration, - (rawPayload.endTime && rawPayload.startTime) - ? Math.round((new Date(rawPayload.endTime) - new Date(rawPayload.startTime)) / 1000) - : ensureNumber(sessionContext.duration, 0) - ); - const startTime = rawPayload.startTime - ? new Date(rawPayload.startTime).toISOString() - : (sessionContext.startTime - ? new Date(sessionContext.startTime).toISOString() - : new Date(new Date(completedAt).getTime() - duration * 1000).toISOString()); - const endTime = rawPayload.endTime - ? new Date(rawPayload.endTime).toISOString() - : completedAt; - const category = deriveCategory(rawPayload, examEntry, metadata); - const frequency = deriveFrequency(rawPayload, examEntry, metadata); - const title = rawPayload.title - || metadata.examTitle - || metadata.title - || (examEntry && examEntry.title) - || resolvedExamId - || '未命名练习'; - const resolvedHighlights = Array.isArray(rawPayload.highlights) - ? rawPayload.highlights.slice() - : (Array.isArray(rawPayload.realData && rawPayload.realData.highlights) - ? rawPayload.realData.highlights.slice() - : (Array.isArray(sessionContext.highlights) ? sessionContext.highlights.slice() : [])); - const resolvedMarkedQuestions = Array.isArray(rawPayload.markedQuestions) - ? rawPayload.markedQuestions.slice() - : (Array.isArray(rawPayload.realData && rawPayload.realData.markedQuestions) - ? rawPayload.realData.markedQuestions.slice() - : (Array.isArray(sessionContext.markedQuestions) ? sessionContext.markedQuestions.slice() : [])); - const resolvedScrollY = Number.isFinite(Number(rawPayload.scrollY)) - ? Number(rawPayload.scrollY) - : (Number.isFinite(Number(rawPayload.realData && rawPayload.realData.scrollY)) - ? Number(rawPayload.realData.scrollY) - : (Number.isFinite(Number(sessionContext.scrollY)) ? Number(sessionContext.scrollY) : 0)); - const resolvedNoteText = typeof rawPayload.noteText === 'string' - ? rawPayload.noteText - : (typeof rawPayload.realData?.noteText === 'string' - ? rawPayload.realData.noteText - : (typeof sessionContext.noteText === 'string' ? sessionContext.noteText : '')); - const resolvedQuestionTypeMap = isPlainObject(rawPayload.questionTypeMap) - ? clonePlainObject(rawPayload.questionTypeMap) - : (isPlainObject(rawPayload.realData && rawPayload.realData.questionTypeMap) - ? clonePlainObject(rawPayload.realData.questionTypeMap) - : {}); - const suiteEntries = rawPayload.suiteEntries || metadata.suiteEntries || []; - const suiteSessionId = rawPayload.suiteSessionId || metadata.suiteSessionId || sessionContext.suiteSessionId || null; - - return standardizeRecord({ - id: rawPayload.id, - examId: resolvedExamId, - sessionId: rawPayload.sessionId || sessionContext.sessionId || null, - title, - type: rawPayload.type || metadata.type || metadata.examType || (examEntry && examEntry.type) || sessionContext.type || null, - startTime, - endTime, - duration, - date: completedAt, - status: rawPayload.status || 'completed', - score: ensureNumber(rawPayload.score ?? scoreInfo.score, correctAnswers), - totalQuestions, - correctAnswers, - accuracy, - answers: answerList, - answerDetails, - correctAnswerMap, - answerComparison, - questionTypePerformance: rawPayload.questionTypePerformance || {}, - metadata: Object.assign({}, metadata, { - examId: resolvedExamId, - examTitle: title, - category, - frequency, - markedQuestions: resolvedMarkedQuestions.slice() - }), - frequency, - suiteMode: Boolean(rawPayload.suiteMode || (String(rawPayload.practiceMode || metadata.practiceMode || '').toLowerCase() === 'suite')), - suiteSessionId, - suiteEntries, - highlights: resolvedHighlights.slice(), - scrollY: resolvedScrollY, - markedQuestions: resolvedMarkedQuestions.slice(), - noteText: resolvedNoteText, - questionTypeMap: resolvedQuestionTypeMap, - scoreInfo: Object.assign({}, scoreInfo, { - correct: correctAnswers, - total: totalQuestions, - accuracy, - percentage, - details: scoreInfo.details || answerDetails, - source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page' - }), - realData: Object.assign({}, rawPayload.realData || {}, { - answers: answerMap, - correctAnswers: correctAnswerMap, - answerComparison, - correctAnswerMap, - highlights: resolvedHighlights.slice(), - scrollY: resolvedScrollY, - markedQuestions: resolvedMarkedQuestions.slice(), - noteText: resolvedNoteText, - questionTypeMap: resolvedQuestionTypeMap, - scoreInfo: Object.assign({}, (rawPayload.realData && rawPayload.realData.scoreInfo) || scoreInfo, { - correct: correctAnswers, - total: totalQuestions, - accuracy, - percentage, - details: answerDetails, - source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page' - }), - interactions: rawPayload.interactions || [], - isRealData: true, - source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page', - sessionId: rawPayload.sessionId || sessionContext.sessionId || null - }) - }, options); - } - - function getRepositories() { - return internalRepositories; - } - - function getStorageManager(storageManager) { - return storageManager || global.persistentStore || global.storage || null; - } - - function getStorageInternalOptions(storage) { - // 仓库注入后用 token 化选项,确保 fallback 读写能通过 storage 的 internal-only 保护。 - if (internalStorageAccess && typeof internalStorageAccess.createInternalOptions === 'function') { - try { - return internalStorageAccess.createInternalOptions({}); - } catch (_) { - // fallthrough 到旧行为 - } - } - return { skipPracticeCoreRedirect: true }; - } - - function syncPracticeRecordState(records) { - const syncAppState = (nextRecords) => { - try { - if (global.app && global.app.state && global.app.state.practice) { - global.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : []; - } - } catch (_) {} - }; - - if (typeof global.setPracticeRecordsState === 'function') { - try { - const finalRecords = global.setPracticeRecordsState(records); - syncAppState(finalRecords); - return; - } catch (error) { - console.warn('[PracticeCore] 同步 practice records 状态失败:', error); - } - } - syncAppState(records); - } - - async function readPracticeRecords(storageManager) { - const repos = getRepositories(); - if (repos && repos.practice && typeof repos.practice.list === 'function') { - return await repos.practice.list(); - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.readPersistentValue === 'function') { - return await storage.readPersistentValue(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage)); - } - if (storage && typeof storage.get === 'function') { - return await storage.get(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage)); - } - return []; - } - - /** - * 轻量投影:读取原始数组(clone:false 跳过 structuredClone),映射为精简 summary 对象。 - * 排除 answers/answerDetails/correctAnswerMap/suiteEntries[]/realData/answerComparison 等重字段, - * 供练习历史列表、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用, - * 避免大数据量下反序列化+克隆全部记录导致的前端渲染卡顿和内存溢出。 - */ - function projectRecordSummary(record) { - if (!record || typeof record !== 'object') { - return null; - } - const scoreInfo = record.scoreInfo || {}; - const metadata = record.metadata || {}; - // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData - const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : []; - const suiteEntries = rawSuiteEntries.map(function (entry) { - if (!entry || typeof entry !== 'object') { return null; } - const entryMeta = entry.metadata || {}; - const entryScore = entry.scoreInfo || {}; - return { - id: entry.id || '', - examId: entry.examId || entryMeta.examId || '', - title: entry.title || entryMeta.examTitle || '', - percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0, - duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0 - }; - }).filter(Boolean); - return { - id: record.id || record.sessionId || '', - sessionId: record.sessionId || null, - examId: record.examId || metadata.examId || null, - title: record.title || metadata.examTitle || '', - type: record.type || metadata.type || 'reading', - practiceType: record.practiceType || metadata.practiceType || metadata.examType || null, - url: record.url || metadata.url || null, - startTime: record.startTime || null, - endTime: record.endTime || null, - date: record.date || null, - duration: Number(record.duration ?? scoreInfo.duration ?? scoreInfo.timeSpent) || 0, - percentage: Number(record.percentage ?? scoreInfo.percentage) || 0, - accuracy: Number(record.accuracy ?? scoreInfo.accuracy) || 0, - score: Number(record.score ?? scoreInfo.score) || 0, - totalQuestions: Number(record.totalQuestions ?? scoreInfo.total) || 0, - correctAnswers: Number(record.correctAnswers ?? scoreInfo.correct) || 0, - status: record.status || 'completed', - suiteMode: Boolean(record.suiteMode), - suiteEntryCount: rawSuiteEntries.length, - suiteEntries: suiteEntries, - suiteSessionId: record.suiteSessionId || (metadata.suiteSessionId) || null, - // questionTypePerformance 是小对象(每题型 {total,correct}),不是重字段,保留供 recalculateStats 使用 - questionTypePerformance: record.questionTypePerformance || null, - // 轻量 scoreInfo 子集:供 accuracy/duration 等 fallback 读取 - scoreInfo: { - accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null, - duration: scoreInfo.duration != null ? scoreInfo.duration : null, - timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null, - percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null, - score: scoreInfo.score != null ? scoreInfo.score : null, - total: scoreInfo.total != null ? scoreInfo.total : null, - correct: scoreInfo.correct != null ? scoreInfo.correct : null - }, - metadata: { - category: metadata.category || record.category || null, - examTitle: metadata.examTitle || record.title || '', - frequency: metadata.frequency || record.frequency || 'unknown', - type: metadata.type || record.type || null, - examType: metadata.examType || null, - practiceType: metadata.practiceType || null, - examId: metadata.examId || null, - title: metadata.title || null, - url: metadata.url || null - }, - updatedAt: record.updatedAt || null, - createdAt: record.createdAt || null - }; - } - - async function readPracticeRecordSummaries(storageManager) { - const repos = getRepositories(); - let records; - if (repos && repos.practice && typeof repos.practice.read === 'function') { - // clone:false 跳过 structuredClone,在投影后原始重字段不会进入返回值 - records = await repos.practice.read({ clone: false }); - } else { - records = await readPracticeRecords(storageManager); - } - if (!Array.isArray(records)) { - return []; - } - return records - .map(projectRecordSummary) - .filter(Boolean); - } - - /** - * 轻量计数:使用 repository.count()(clone:false + .length),不构造 summary 数组。 - */ - async function countPracticeRecords(storageManager) { - const repos = getRepositories(); - if (repos && repos.practice && typeof repos.practice.count === 'function') { - return await repos.practice.count(); - } - const records = await readPracticeRecords(storageManager); - return Array.isArray(records) ? records.length : 0; - } - - async function writePracticeRecords(records, storageManager) { - const finalRecords = Array.isArray(records) ? records : []; - const repos = getRepositories(); - if (repos && repos.practice && typeof repos.practice.overwrite === 'function') { - await repos.practice.overwrite(finalRecords); - syncPracticeRecordState(finalRecords); - return true; - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.writePersistentValue === 'function') { - const result = await storage.writePersistentValue(STORAGE_KEYS.practiceRecords, finalRecords, getStorageInternalOptions(storage)); - syncPracticeRecordState(finalRecords); - return result; - } - return false; - } - - async function readMeta(key, defaultValue, storageManager) { - const repos = getRepositories(); - if (repos && repos.meta && typeof repos.meta.get === 'function') { - return await repos.meta.get(key, defaultValue); - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.readPersistentValue === 'function') { - return await storage.readPersistentValue(key, defaultValue, getStorageInternalOptions(storage)); - } - if (storage && typeof storage.get === 'function') { - return await storage.get(key, defaultValue, getStorageInternalOptions(storage)); - } - return defaultValue; - } - - async function writeMeta(key, value, storageManager) { - const repos = getRepositories(); - if (repos && repos.meta && typeof repos.meta.set === 'function') { - await repos.meta.set(key, value); - return true; - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.writePersistentValue === 'function') { - return await storage.writePersistentValue(key, value, getStorageInternalOptions(storage)); - } - return false; - } - - async function removeMeta(key, storageManager) { - const repos = getRepositories(); - if (repos && repos.meta && typeof repos.meta.remove === 'function') { - await repos.meta.remove(key); - return true; - } - const storage = getStorageManager(storageManager); - if (storage && typeof storage.removePersistentValue === 'function') { - return await storage.removePersistentValue(key, getStorageInternalOptions(storage)); - } - return false; - } - - function extractSessionId(record) { - if (!record || typeof record !== 'object') { - return null; - } - const rawId = record.sessionId - || (record.realData && record.realData.sessionId) - || (record.metadata && record.metadata.sessionId) - || null; - if (!rawId) return null; - return String(rawId).trim() || null; - } - - function dedupePracticeRecords(records) { - // 仅按 record.id 去重,不按 sessionId 全局去重。 - // sessionId 在套题场景中是容器标识,不是 attempt 唯一键; - // 多条不同 id 的记录可能共享同一 sessionId(如同一套题的不同 passage), - // 按 sessionId 去重会永久丢弃合法记录。 - const seenIds = new Set(); - const deduped = []; - - (Array.isArray(records) ? records : []).forEach((record) => { - if (!record || typeof record !== 'object') { - return; - } - const recordId = record.id != null ? String(record.id) : null; - - if (recordId && seenIds.has(recordId)) { - return; - } - - if (recordId) seenIds.add(recordId); - deduped.push(record); - }); - - return deduped; - } - - function getRecordTimestamp(record) { - if (!record || typeof record !== 'object') { - return 0; - } - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.date, - record.timestamp - ]; - for (let index = 0; index < candidates.length; index += 1) { - const value = candidates[index]; - if (!value) { - continue; - } - const time = new Date(value).getTime(); - if (Number.isFinite(time)) { - return time; - } - } - return 0; - } - - function handlesStorageKey(key) { - return key === STORAGE_KEYS.practiceRecords - || key === STORAGE_KEYS.userStats - || key === STORAGE_KEYS.activeSessions - || key === STORAGE_KEYS.tempPracticeRecords; - } - - async function replacePracticeRecords(records, options = {}) { - const canonical = dedupePracticeRecords( - (Array.isArray(records) ? records : []).map((record) => standardizeRecord(record, options)) - ); - canonical.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a)); - if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && canonical.length > options.maxRecords) { - canonical.splice(options.maxRecords); - } - return await writePracticeRecords(canonical, options.storageManager); - } - - async function savePracticeRecord(record, options = {}) { - const standardizedRecord = standardizeRecord(record, options); - let records = await readPracticeRecords(options.storageManager); - records = Array.isArray(records) ? records.slice() : []; - - const existingIndex = records.findIndex((entry) => entry && String(entry.id) === String(standardizedRecord.id)); - if (existingIndex >= 0) { - records[existingIndex] = standardizedRecord; - } else { - records.unshift(standardizedRecord); - } - - // 仅当同一 sessionId 且同一 examId 时才移除旧记录(同一篇练习的重复提交覆盖)。 - // 不同 examId 但共享 sessionId 的记录(如套题不同 passage)必须保留。 - const standardizedSessionId = extractSessionId(standardizedRecord); - const standardizedExamId = standardizedRecord.examId || null; - if (standardizedSessionId) { - records = records.filter((entry, index) => { - if (index === 0) { - return true; - } - const sessionId = extractSessionId(entry); - const examId = entry && entry.examId || null; - const sameSession = sessionId && sessionId === standardizedSessionId; - const sameExam = standardizedExamId && examId && examId === standardizedExamId; - return !(sameSession && sameExam && String(entry.id) !== String(standardizedRecord.id)); - }); - } - - records = dedupePracticeRecords(records); - records.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a)); - if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && records.length > options.maxRecords) { - records.splice(options.maxRecords); - } - await writePracticeRecords(records, options.storageManager); - return standardizedRecord; - } - - async function routeStorageSet(storageManager, key, value, options = {}) { - if (key === STORAGE_KEYS.practiceRecords) { - return await replacePracticeRecords(value, { - currentVersion: options.currentVersion || '0.6.2-fix', - maxRecords: options.maxRecords || 1000, - storageManager - }); - } - if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) { - return await writeMeta(key, value, storageManager); - } - return null; - } - - async function routeStorageRemove(storageManager, key) { - if (key === STORAGE_KEYS.practiceRecords) { - return await writePracticeRecords([], storageManager); - } - if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) { - return await removeMeta(key, storageManager); - } - return null; - } - - const contracts = Object.freeze({ - ensureNumber, - normalizePracticeType, - inferPracticeType, - resolveRecordDate, - inferExamId, - normalizeAnswerValue, - isNoiseKey, - normalizeAnswerMap, - normalizeReplayQuestionKey, - normalizeReplayMap, - normalizeAnswerComparison, - mergeAnswerSources, - buildReplayCorrectAnswerMap, - buildReplayResultSnapshot, - resolveCorrectAnswerMap, - resolveRecordCorrectAnswerMap, - compareAnswerValues, - buildAnswerArray, - buildAnswerDetails, - deriveCorrectMapFromDetails, - deriveCorrectAnswerCount, - deriveTotalQuestionCount, - convertComparisonToMap, - convertComparisonToDetails, - buildMetadata, - standardizeRecord, - standardizeSuiteEntries, - clonePlainObject - }); - - const protocol = Object.freeze({ - MESSAGE_TYPE_ALIASES, - PRACTICE_COMPLETE_TYPES, - normalizeMessageType, - normalizeMessage, - isPracticeCompleteType, - buildEnvelope - }); - - const ingestor = Object.freeze({ - fromCompletion - }); - - const internalStore = Object.freeze({ - STORAGE_KEYS, - handlesStorageKey, - listPracticeRecords: readPracticeRecords, - listPracticeRecordSummaries: readPracticeRecordSummaries, - countPracticeRecords, - replacePracticeRecords, - savePracticeRecord, - routeStorageSet, - routeStorageRemove, - readMeta, - writeMeta, - removeMeta, - syncPracticeRecordState - }); - - const publicStore = Object.freeze({ - STORAGE_KEYS, - handlesStorageKey, - listPracticeRecords: readPracticeRecords, - listPracticeRecordSummaries: readPracticeRecordSummaries, - countPracticeRecords, - readMeta, - syncPracticeRecordState - }); - - const practiceCore = { - __stable: true, - version: '0.6.2-fix', - contracts, - protocol, - ingestor, - store: publicStore - }; - Object.defineProperty(practiceCore, '__installRecordAPI', { - value: function(install) { - if (typeof install !== 'function') { - throw new Error('PracticeCore.__installRecordAPI requires an installer function'); - } - return install(internalStore); - }, - enumerable: false, - configurable: true, - writable: false - }); - Object.defineProperty(practiceCore, '__installInternalRepositories', { - value: function(repositories, installers) { - if (!repositories || typeof repositories !== 'object') { - throw new Error('PracticeCore.__installInternalRepositories requires repositories'); - } - internalRepositories = repositories; - // 接收 storage internal token factory,供 fallback 路径使用。 - if (installers && typeof installers.createInternalOptions === 'function') { - internalStorageAccess = { createInternalOptions: installers.createInternalOptions }; - } - try { - delete practiceCore.__installInternalRepositories; - } catch (_) { - practiceCore.__installInternalRepositories = undefined; - } - return true; - }, - enumerable: false, - configurable: true, - writable: false - }); - global.PracticeCore = practiceCore; -})(typeof window !== 'undefined' ? window : globalThis); - - -/* ===== js/core/practiceRecordAPI.js ===== */ -(function initPracticeRecordAPI(global) { - 'use strict'; - - const DEFAULT_VERSION = '0.6.2-fix'; - const DEFAULT_MAX_RECORDS = 1000; - - if (global.PracticeRecordAPI && global.PracticeRecordAPI.__stable === true) { - return; - } - - let recordStore = null; - - function getPracticeCore() { - return global.PracticeCore || null; - } - - function installRecordStore() { - if (recordStore) { - return recordStore; - } - const core = getPracticeCore(); - if (!core || typeof core.__installRecordAPI !== 'function') { - return null; - } - recordStore = core.__installRecordAPI((store) => store || null); - try { - delete core.__installRecordAPI; - } catch (_) { - core.__installRecordAPI = undefined; - } - return recordStore; - } - - function getRecordStore() { - return recordStore || installRecordStore(); - } - - installRecordStore(); - - function getDefaultSaveOptions(options = {}) { - const source = options && typeof options === 'object' ? options : {}; - const normalized = { - currentVersion: source.currentVersion || DEFAULT_VERSION, - maxRecords: DEFAULT_MAX_RECORDS - }; - const maxRecords = Number(source.maxRecords); - if (Number.isFinite(maxRecords) && maxRecords > 0) { - normalized.maxRecords = maxRecords; - } - Object.keys(source).forEach((key) => { - if (source[key] !== undefined) { - normalized[key] = source[key]; - } - }); - normalized.currentVersion = normalized.currentVersion || DEFAULT_VERSION; - normalized.maxRecords = Number.isFinite(Number(normalized.maxRecords)) && Number(normalized.maxRecords) > 0 - ? Number(normalized.maxRecords) - : DEFAULT_MAX_RECORDS; - return normalized; - } - - function toIdString(value) { - return value == null ? '' : String(value); - } - - function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); - } - - function clonePlainObject(value) { - if (value == null || typeof value !== 'object') { - return value ?? null; - } - if (Array.isArray(value)) { - return value.map((item) => clonePlainObject(item)); - } - const clone = {}; - Object.keys(value).forEach((key) => { - clone[key] = clonePlainObject(value[key]); - }); - return clone; - } - - function getDefaultStats() { - if (global.ExamData && typeof global.ExamData.createDefaultUserStats === 'function') { - return clonePlainObject(global.ExamData.createDefaultUserStats()); - } - const now = new Date().toISOString(); - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [], - createdAt: now, - updatedAt: now - }; - } - - function toCamelCaseKey(key) { - return String(key) - .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase()) - .replace(/^[A-Z]/, match => match.toLowerCase()); - } - - function normalizeStatsAliases(stats) { - if (!isPlainObject(stats)) { - return {}; - } - const normalized = {}; - Object.entries(stats).forEach(([key, value]) => { - normalized[toCamelCaseKey(key)] = value; - }); - return normalized; - } - - function prepareStats(stats) { - const source = normalizeStatsAliases(stats); - const prepared = Object.assign({}, getDefaultStats(), clonePlainObject(source)); - prepared.categoryStats = isPlainObject(source.categoryStats) ? clonePlainObject(source.categoryStats) : {}; - prepared.questionTypeStats = isPlainObject(source.questionTypeStats) ? clonePlainObject(source.questionTypeStats) : {}; - prepared.practiceDays = Array.isArray(source.practiceDays) ? source.practiceDays.slice() : []; - prepared.achievements = Array.isArray(source.achievements) ? source.achievements.slice() : []; - prepared.updatedAt = source.updatedAt || new Date().toISOString(); - return prepared; - } - - function getCoreContracts() { - const core = getPracticeCore(); - return core && core.contracts ? core.contracts : null; - } - - function normalizeRecord(record, options = {}) { - if (!isPlainObject(record)) { - return null; - } - - const contracts = getCoreContracts(); - if (!contracts || typeof contracts.standardizeRecord !== 'function') { - throw new Error('PracticeRecordAPI.normalizeRecord: PracticeCore.contracts.standardizeRecord not ready'); - } - - const preserveIds = options.preserveIds !== false; - const safePrefix = options.fallbackIdPrefix || 'record'; - const sourceId = record.id - ?? record.recordId - ?? record.record_id - ?? record.practiceId - ?? record.practice_id - ?? record.sessionId - ?? record.sessionID - ?? record.timestamp - ?? record.uuid; - const candidate = clonePlainObject(record) || {}; - - let id = preserveIds && sourceId ? String(sourceId).trim() : ''; - if (!id) { - const index = Number.isFinite(Number(options.index)) ? Number(options.index) : 0; - id = `${safePrefix}_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`; - } - candidate.id = id; - - if (record.recordStatus !== undefined && candidate.status === undefined) { - candidate.status = record.recordStatus; - } - - const generateRecordId = typeof options.generateRecordId === 'function' - ? options.generateRecordId - : () => id; - const standardized = contracts.standardizeRecord(candidate, Object.assign({}, options, { - currentVersion: options.currentVersion || DEFAULT_VERSION, - generateRecordId - })); - return standardized && standardized.examId ? standardized : null; - } - - function normalizeDateValue(value) { - if (!value) { - return null; - } - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString(); - } - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (/^\d+$/.test(trimmed)) { - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000; - return new Date(milliseconds).toISOString(); - } - } - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return null; - } - - function getRecordTimestamp(record) { - if (!record || typeof record !== 'object') { - return 0; - } - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.timestamp, - record.date - ]; - for (let index = 0; index < candidates.length; index += 1) { - const iso = normalizeDateValue(candidates[index]); - if (iso) { - const time = new Date(iso).getTime(); - if (Number.isFinite(time)) { - return time; - } - } - } - return 0; - } - - function mergeRecordDetails(existing, incoming, options = {}) { - const merged = Object.assign({}, existing || {}, incoming || {}); - if (isPlainObject(existing && existing.metadata) || isPlainObject(incoming && incoming.metadata)) { - merged.metadata = Object.assign( - {}, - isPlainObject(existing && existing.metadata) ? existing.metadata : {}, - isPlainObject(incoming && incoming.metadata) ? incoming.metadata : {} - ); - } - if (isPlainObject(existing && existing.realData) || isPlainObject(incoming && incoming.realData)) { - merged.realData = Object.assign( - {}, - isPlainObject(existing && existing.realData) ? existing.realData : {}, - isPlainObject(incoming && incoming.realData) ? incoming.realData : {} - ); - } - return normalizeRecord(merged, Object.assign({}, options, { - generateRecordId: () => String(merged.id || (incoming && incoming.id) || (existing && existing.id) || `record_${Date.now()}`) - })); - } - - async function readStats(options = {}) { - const fallback = Object.prototype.hasOwnProperty.call(options, 'fallback') - ? options.fallback - : getDefaultStats(); - - const store = getRecordStore(); - if (!store || typeof store.readMeta !== 'function') { - throw new Error('PracticeRecordAPI.readStats: unified meta store not ready'); - } - - return prepareStats(await store.readMeta('user_stats', fallback)); - } - - async function writeStats(stats) { - const finalStats = prepareStats(stats); - const store = getRecordStore(); - - if (store && typeof store.writeMeta === 'function') { - await store.writeMeta('user_stats', finalStats); - return finalStats; - } - - throw new Error('PracticeRecordAPI.writeStats: unified meta store not ready'); - } - - function normalizeDay(value) { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - return date.toISOString().slice(0, 10); - } - - function calculateStreakDays(days) { - const sorted = Array.isArray(days) ? days.slice().sort() : []; - if (sorted.length === 0) return 0; - let streak = 1; - for (let index = sorted.length - 1; index > 0; index -= 1) { - const current = new Date(sorted[index]); - const previous = new Date(sorted[index - 1]); - const diffDays = Math.round((current - previous) / 86400000); - if (diffDays === 1) { - streak += 1; - continue; - } - if (diffDays > 1) break; - } - return streak; - } - - function normalizeAccuracyForStats(record) { - const values = [ - record && record.accuracy, - record && record.scoreInfo && record.scoreInfo.accuracy, - record && record.realData && record.realData.scoreInfo && record.realData.scoreInfo.accuracy - ]; - for (let index = 0; index < values.length; index += 1) { - const numeric = Number(values[index]); - if (Number.isFinite(numeric)) { - if (numeric > 1 && numeric <= 100) { - return numeric / 100; - } - return Math.max(0, Math.min(1, numeric)); - } - } - const correct = Number(record && (record.correctAnswers ?? record.scoreInfo?.correct ?? record.score)); - const total = Number(record && (record.totalQuestions ?? record.scoreInfo?.total)); - return Number.isFinite(correct) && Number.isFinite(total) && total > 0 - ? Math.max(0, Math.min(1, correct / total)) - : 0; - } - - function applyRecordToStats(stats, record) { - if (!stats || !record || typeof record !== 'object') { - return; - } - - const duration = Math.max(0, Number(record.duration) || 0); - const accuracy = normalizeAccuracyForStats(record); - const category = String((record.metadata && record.metadata.category) || record.category || record.type || '').trim(); - const day = normalizeDay(record.date || record.endTime || record.startTime || record.createdAt); - - stats.totalPractices += 1; - stats.totalTimeSpent += duration; - const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy; - stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0; - - stats.categoryStats = isPlainObject(stats.categoryStats) ? stats.categoryStats : {}; - if (category) { - if (!stats.categoryStats[category]) { - stats.categoryStats[category] = { - practices: 0, - avgScore: 0, - timeSpent: 0, - bestScore: 0, - totalQuestions: 0, - correctAnswers: 0 - }; - } - const categoryStats = stats.categoryStats[category]; - categoryStats.practices += 1; - categoryStats.timeSpent += duration; - categoryStats.bestScore = Math.max(categoryStats.bestScore || 0, accuracy); - categoryStats.totalQuestions += Number(record.totalQuestions) || 0; - categoryStats.correctAnswers += Number(record.correctAnswers) || 0; - categoryStats.avgScore = ((categoryStats.avgScore || 0) * (categoryStats.practices - 1) + accuracy) / categoryStats.practices; - } - - stats.questionTypeStats = isPlainObject(stats.questionTypeStats) ? stats.questionTypeStats : {}; - if (isPlainObject(record.questionTypePerformance)) { - Object.entries(record.questionTypePerformance).forEach(([type, performance]) => { - if (!stats.questionTypeStats[type]) { - stats.questionTypeStats[type] = { - practices: 0, - accuracy: 0, - totalQuestions: 0, - correctAnswers: 0 - }; - } - const typeStats = stats.questionTypeStats[type]; - typeStats.practices += 1; - typeStats.totalQuestions += Number(performance && performance.total) || 0; - typeStats.correctAnswers += Number(performance && performance.correct) || 0; - typeStats.accuracy = typeStats.totalQuestions > 0 - ? typeStats.correctAnswers / typeStats.totalQuestions - : 0; - }); - } - - if (day) { - const days = new Set(Array.isArray(stats.practiceDays) ? stats.practiceDays : []); - days.add(day); - stats.practiceDays = Array.from(days).sort(); - stats.lastPracticeDate = stats.practiceDays[stats.practiceDays.length - 1] || null; - stats.streakDays = calculateStreakDays(stats.practiceDays); - } - stats.updatedAt = new Date().toISOString(); - } - - async function recalculateStats() { - // 使用轻量 listSummary 避免反序列化+克隆完整记录(answers/suiteEntries/realData 等重字段)。 - // summary 已包含 applyRecordToStats 所需的全部字段:duration, accuracy, metadata.category, - // date/endTime/startTime/createdAt, totalQuestions, correctAnswers, questionTypePerformance。 - const records = await listSummary(); - const stats = getDefaultStats(); - (Array.isArray(records) ? records : []).forEach((record) => applyRecordToStats(stats, record)); - return await writeStats(stats); - } - - async function resetStats(stats = null) { - return await writeStats(isPlainObject(stats) ? stats : getDefaultStats()); - } - - async function mergeStats(stats, options = {}) { - if (!isPlainObject(stats)) { - return await readStats(); - } - - const mergeMode = options.mergeMode || options.mode || 'merge'; - if (mergeMode === 'replace') { - return await writeStats(stats); - } - - const existing = await readStats({ fallback: {} }); - const merged = Object.assign({}, existing); - Object.entries(stats).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - const current = existing[key]; - if (typeof value === 'number' && typeof current === 'number') { - merged[key] = Math.max(value, current); - return; - } - if (isPlainObject(value) && isPlainObject(current)) { - merged[key] = Object.assign({}, current, value); - return; - } - merged[key] = clonePlainObject(value); - }); - - return await writeStats(merged); - } - - async function updateStatsForSavedRecord(record, options = {}) { - if (!record || options.updateStats === false) { - return false; - } - - await recalculateStats(); - return true; - } - - async function list() { - const store = getRecordStore(); - if (!store || typeof store.listPracticeRecords !== 'function') { - throw new Error('PracticeRecordAPI.list: unified store not ready'); - } - - const records = await store.listPracticeRecords(); - return Array.isArray(records) ? records : []; - } - - /** - * 轻量投影查询:返回每条记录的元数据摘要,不含 answers/correctAnswerMap/ - * suiteEntries[]/realData 等重字段。底层以 clone:false 读取原始数组后即时映射, - * 避免大数据量下 structuredClone 全部记录导致内存溢出和渲染卡顿。 - * 供练习历史列表签名、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用。 - */ - async function listSummary(options = {}) { - const store = getRecordStore(); - if (!store || typeof store.listPracticeRecordSummaries !== 'function') { - // 回退:store 尚未支持 summary 时从完整记录投影 - const records = await list(); - return records.map(_projectSummary).filter(Boolean); - } - const summaries = await store.listPracticeRecordSummaries(); - return Array.isArray(summaries) ? summaries : []; - } - - /** 返回记录总数,不加载记录数组到内存 */ - async function count(options = {}) { - const store = getRecordStore(); - if (store && typeof store.countPracticeRecords === 'function') { - return await store.countPracticeRecords(); - } - // 回退:store 不支持 count 时从 summary 长度获取 - if (store && typeof store.listPracticeRecordSummaries === 'function') { - const summaries = await store.listPracticeRecordSummaries(); - return Array.isArray(summaries) ? summaries.length : 0; - } - const records = await list(); - return Array.isArray(records) ? records.length : 0; - } - - /** 返回去重后的 examId 列表,供 overview 统计使用 */ - async function distinctExamIds(options = {}) { - const summaries = await listSummary(options); - const seen = new Set(); - const result = []; - for (let i = 0; i < summaries.length; i += 1) { - const examId = summaries[i] && summaries[i].examId; - if (examId && !seen.has(examId)) { - seen.add(examId); - result.push(examId); - } - } - return result; - } - - /** 纯函数投影:从单条完整记录提取轻量 summary */ - function _projectSummary(record) { - if (!record || typeof record !== 'object') { - return null; - } - const scoreInfo = record.scoreInfo || {}; - const metadata = record.metadata || {}; - // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData - const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : []; - const suiteEntries = rawSuiteEntries.map(function (entry) { - if (!entry || typeof entry !== 'object') { return null; } - const entryMeta = entry.metadata || {}; - const entryScore = entry.scoreInfo || {}; - return { - id: entry.id || '', - examId: entry.examId || entryMeta.examId || '', - title: entry.title || entryMeta.examTitle || '', - percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0, - duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0 - }; - }).filter(Boolean); - return { - id: record.id || record.sessionId || '', - sessionId: record.sessionId || null, - examId: record.examId || metadata.examId || null, - title: record.title || metadata.examTitle || '', - type: record.type || metadata.type || 'reading', - practiceType: record.practiceType || metadata.practiceType || metadata.examType || null, - url: record.url || metadata.url || null, - startTime: record.startTime || null, - endTime: record.endTime || null, - date: record.date || null, - duration: Number(record.duration != null ? record.duration : (scoreInfo.duration != null ? scoreInfo.duration : scoreInfo.timeSpent)) || 0, - percentage: Number(record.percentage != null ? record.percentage : scoreInfo.percentage) || 0, - accuracy: Number(record.accuracy != null ? record.accuracy : scoreInfo.accuracy) || 0, - score: Number(record.score != null ? record.score : scoreInfo.score) || 0, - totalQuestions: Number(record.totalQuestions != null ? record.totalQuestions : scoreInfo.total) || 0, - correctAnswers: Number(record.correctAnswers != null ? record.correctAnswers : scoreInfo.correct) || 0, - status: record.status || 'completed', - suiteMode: Boolean(record.suiteMode), - suiteEntryCount: rawSuiteEntries.length, - suiteEntries: suiteEntries, - suiteSessionId: record.suiteSessionId || metadata.suiteSessionId || null, - questionTypePerformance: record.questionTypePerformance || null, - scoreInfo: { - accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null, - duration: scoreInfo.duration != null ? scoreInfo.duration : null, - timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null, - percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null, - score: scoreInfo.score != null ? scoreInfo.score : null, - total: scoreInfo.total != null ? scoreInfo.total : null, - correct: scoreInfo.correct != null ? scoreInfo.correct : null - }, - metadata: { - category: metadata.category || record.category || null, - examTitle: metadata.examTitle || record.title || '', - frequency: metadata.frequency || record.frequency || 'unknown', - type: metadata.type || record.type || null, - examType: metadata.examType || null, - practiceType: metadata.practiceType || null, - examId: metadata.examId || null, - title: metadata.title || null, - url: metadata.url || null - }, - updatedAt: record.updatedAt || null, - createdAt: record.createdAt || null - }; - } - - async function getById(recordId) { - const targetId = toIdString(recordId); - if (!targetId) { - return null; - } - const records = await list(); - return records.find((record) => { - if (!record || typeof record !== 'object') { - return false; - } - return toIdString(record.id) === targetId || toIdString(record.sessionId) === targetId; - }) || null; - } - - async function replace(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('PracticeRecordAPI.replace requires an array of records'); - } - const finalRecords = records; - const saveOptions = getDefaultSaveOptions(options); - const store = getRecordStore(); - if (store && typeof store.replacePracticeRecords === 'function') { - await store.replacePracticeRecords(finalRecords, saveOptions); - if (options.updateStats !== false) { - await recalculateStats(); - } - return finalRecords; - } - - throw new Error('PracticeRecordAPI.replace: unified store not ready'); - } - - async function mergeRecords(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('PracticeRecordAPI.mergeRecords requires an array of records'); - } - - const mergeMode = options.mergeMode || options.mode || 'merge'; - const normalizeOptions = getDefaultSaveOptions(options); - const incomingRecords = records - .map((record, index) => normalizeRecord(record, Object.assign({}, normalizeOptions, { - preserveIds: options.preserveIds !== false, - fallbackIdPrefix: options.fallbackIdPrefix || 'record', - index - }))) - .filter(Boolean); - const existingRecords = await list(); - - if (mergeMode === 'replace') { - await replace(incomingRecords, Object.assign({}, options, { updateStats: options.updateStats !== false })); - return { - importedCount: incomingRecords.length, - updatedCount: existingRecords.length, - skippedCount: 0, - finalCount: incomingRecords.length, - records: incomingRecords - }; - } - - const indexMap = new Map(); - existingRecords.forEach((record, index) => { - if (record && record.id !== undefined && record.id !== null) { - indexMap.set(String(record.id), { record, index }); - } - }); - - const mergedRecords = existingRecords.slice(); - let importedCount = 0; - let updatedCount = 0; - let skippedCount = 0; - - incomingRecords.forEach((record) => { - if (!record || record.id === undefined || record.id === null) { - return; - } - - const key = String(record.id); - const existing = indexMap.get(key); - - if (!existing) { - mergedRecords.push(record); - indexMap.set(key, { record, index: mergedRecords.length - 1 }); - importedCount += 1; - return; - } - - if (mergeMode === 'skip') { - skippedCount += 1; - return; - } - - const existingTimestamp = getRecordTimestamp(existing.record); - const incomingTimestamp = getRecordTimestamp(record); - if (incomingTimestamp >= existingTimestamp) { - const merged = mergeRecordDetails(existing.record, record, normalizeOptions); - mergedRecords[existing.index] = merged; - indexMap.set(key, { record: merged, index: existing.index }); - updatedCount += 1; - return; - } - - skippedCount += 1; - }); - - mergedRecords.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a)); - await replace(mergedRecords, Object.assign({}, options, { updateStats: options.updateStats !== false })); - - return { - importedCount, - updatedCount, - skippedCount, - finalCount: mergedRecords.length, - records: mergedRecords - }; - } - - async function restoreRecords(records, options = {}) { - if (!Array.isArray(records)) { - throw new Error('PracticeRecordAPI.restoreRecords requires an array of records'); - } - - await replace(records, Object.assign({}, options, { updateStats: false })); - if (isPlainObject(options.stats)) { - await writeStats(options.stats); - } else if (options.updateStats !== false) { - await recalculateStats(); - } - return { - restoredCount: records.length, - statsRestored: isPlainObject(options.stats) - }; - } - - async function clear(options = {}) { - await replace([], Object.assign({}, options, { updateStats: false })); - if (options.updateStats === true) { - await resetStats(); - } - return true; - } - - async function deleteMany(recordIds, options = {}) { - const ids = Array.isArray(recordIds) ? recordIds.map(toIdString).filter(Boolean) : []; - if (ids.length === 0) { - return { deletedCount: 0, deletedRecords: [], records: await list() }; - } - - const idSet = new Set(ids); - // 默认仅按 record.id 删除,避免共享 sessionId 的不同记录被误删。 - // matchBy: 'sessionId' 时才按 sessionId 匹配(用于 suite 子记录清理等显式场景)。 - const matchBySessionId = options.matchBy === 'sessionId'; - const records = await list(); - const deletedRecords = []; - const remainingRecords = []; - - (Array.isArray(records) ? records : []).forEach((record) => { - const recordId = toIdString(record && record.id); - const sessionId = toIdString(record && record.sessionId); - const idMatch = recordId && idSet.has(recordId); - const sessionMatch = matchBySessionId && sessionId && idSet.has(sessionId); - if (idMatch || sessionMatch) { - deletedRecords.push(record); - return; - } - remainingRecords.push(record); - }); - - if (deletedRecords.length > 0) { - await replace(remainingRecords, options); - } - - return { - deletedCount: deletedRecords.length, - deletedRecords, - records: remainingRecords - }; - } - - async function deleteById(recordId, options = {}) { - const result = await deleteMany([recordId], options); - return { - deleted: result.deletedCount > 0, - record: result.deletedRecords[0] || null, - records: result.records - }; - } - - async function saveRecord(record, options = {}) { - if (!record || typeof record !== 'object') { - throw new Error('PracticeRecordAPI.saveRecord requires a record object'); - } - - const saveOptions = getDefaultSaveOptions(options); - const store = getRecordStore(); - if (!store || typeof store.savePracticeRecord !== 'function') { - throw new Error('PracticeRecordAPI.saveRecord: PracticeCore store not ready'); - } - const normalizedRecord = normalizeRecord(record, saveOptions); - if (!normalizedRecord || !normalizedRecord.examId) { - throw new Error('PracticeRecordAPI.saveRecord requires a canonical examId'); - } - - const savedRecord = await store.savePracticeRecord(normalizedRecord, saveOptions); - - if (options.updateStats !== false) { - await updateStatsForSavedRecord(savedRecord, options); - } - - return savedRecord; - } - - function fromCompletion(payload, context = {}, examEntry = null, options = {}) { - const core = getPracticeCore(); - if (!core || !core.ingestor || typeof core.ingestor.fromCompletion !== 'function') { - return null; - } - return core.ingestor.fromCompletion(payload, context || {}, examEntry || null, getDefaultSaveOptions(options)); - } - - async function saveCompletion(payload, context = {}, examEntry = null, options = {}) { - const record = fromCompletion(payload, context, examEntry, options); - if (!record) { - throw new Error('PracticeRecordAPI.saveCompletion could not build canonical record'); - } - return await saveRecord(record, options); - } - - function normalizeAccuracy(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric < 0) { - return 0; - } - if (numeric > 1 && numeric <= 100) { - return numeric / 100; - } - return Math.min(numeric, 1); - } - - function toSummaryMetrics(record = {}) { - const total = Number(record.totalQuestions ?? record.scoreInfo?.total ?? record.scoreInfo?.totalQuestions ?? record.realData?.scoreInfo?.total ?? record.realData?.totalQuestions); - const correct = Number(record.correctAnswers ?? record.score ?? record.scoreInfo?.correct ?? record.scoreInfo?.score ?? record.realData?.scoreInfo?.correct ?? record.realData?.score); - const safeTotal = Number.isFinite(total) && total >= 0 ? total : 0; - const safeCorrect = Number.isFinite(correct) && correct >= 0 ? correct : 0; - - let accuracy = normalizeAccuracy(record.accuracy ?? record.scoreInfo?.accuracy ?? record.realData?.scoreInfo?.accuracy ?? (safeTotal > 0 ? safeCorrect / safeTotal : 0)); - const percentageCandidate = Number(record.percentage ?? record.scoreInfo?.percentage ?? record.realData?.scoreInfo?.percentage); - const percentage = Number.isFinite(percentageCandidate) && percentageCandidate >= 0 && percentageCandidate <= 100 - ? percentageCandidate - : Math.round(accuracy * 100); - const hasExplicitAccuracy = record.accuracy != null - || record.scoreInfo?.accuracy != null - || record.realData?.scoreInfo?.accuracy != null; - accuracy = percentage > 1 && !hasExplicitAccuracy - ? percentage / 100 - : accuracy; - - return { - totalQuestions: safeTotal, - correctAnswers: safeCorrect, - accuracy, - percentage, - duration: Number(record.duration ?? record.realData?.duration) || 0 - }; - } - - function toReplayEntries(record, projector) { - if (typeof projector === 'function') { - return projector(record); - } - return []; - } - - global.PracticeRecordAPI = { - __stable: true, - version: '0.6.2-fix', - list, - listSummary, - count, - distinctExamIds, - getById, - replace, - mergeRecords, - restoreRecords, - clear, - deleteById, - deleteMany, - saveRecord, - normalizeRecord, - fromCompletion, - saveCompletion, - toSummaryMetrics, - toReplayEntries, - getDefaultStats, - prepareStats, - readStats, - writeStats, - mergeStats, - resetStats, - recalculateStats, - updateStatsForSavedRecord - }; - - if (global.persistentStore && typeof global.persistentStore.migrateLegacyData === 'function') { - Promise.resolve() - .then(() => global.persistentStore.migrateLegacyData({ skipReady: true })) - .catch((error) => { - console.warn('[PracticeRecordAPI] 延后练习记录迁移失败:', error); - }); - } -})(typeof window !== 'undefined' ? window : globalThis); - - -/* ===== js/core/backupAPI.js ===== */ -(function initBackupAPI(global) { - 'use strict'; - - if (global.BackupAPI && global.BackupAPI.__stable === true) { - return; - } - - const DEFAULT_VERSION = '0.6.2-form'; - const DEFAULT_MAX_BACKUPS = 20; - - function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); - } - - function cloneJson(value) { - if (value == null) return value; - try { - return JSON.parse(JSON.stringify(value)); - } catch (_) { - return value; - } - } - - function getStorageFacade() { - if (global.storage && typeof global.storage.get === 'function') { - return global.storage; - } - // Some boot paths / VM tests expose bare global storage without attaching to window - try { - if (typeof storage !== 'undefined' && storage && typeof storage.get === 'function') { - return storage; - } - } catch (_) { /* ignore ReferenceError in strict scopes */ } - return null; - } - - function getRepositories() { - if (global.dataRepositories && global.dataRepositories.backups) { - return global.dataRepositories; - } - const registry = global.StorageProviderRegistry; - if (registry && typeof registry.getCurrentProviders === 'function') { - const current = registry.getCurrentProviders(); - if (current && current.repositories && current.repositories.backups) { - return current.repositories; - } - } - if (global.simpleStorageWrapper && global.simpleStorageWrapper.backupRepo) { - return { - backups: global.simpleStorageWrapper.backupRepo, - meta: global.simpleStorageWrapper.metaRepo || null, - settings: global.simpleStorageWrapper.settingsRepo || null - }; - } - return null; - } - - function getBackupRepo() { - const repos = getRepositories(); - return repos && repos.backups ? repos.backups : null; - } - - function getMetaRepo() { - const repos = getRepositories(); - return repos && repos.meta ? repos.meta : null; - } - - async function readMeta(key, fallback = null) { - const meta = getMetaRepo(); - if (meta && typeof meta.get === 'function') { - return await meta.get(key, fallback); - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - return await storageFacade.get(key, fallback); - } - return fallback; - } - - async function writeMeta(key, value) { - const meta = getMetaRepo(); - if (meta && typeof meta.set === 'function') { - await meta.set(key, value); - return true; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set(key, value); - return true; - } - throw new Error('BackupAPI: meta store not ready'); - } - - function resolvePracticeRecords(data) { - if (!data || typeof data !== 'object') return null; - if (Array.isArray(data.practice_records)) return data.practice_records; - if (Array.isArray(data.practiceRecords)) return data.practiceRecords; - return null; - } - - function resolveUserStats(data) { - if (!data || typeof data !== 'object') return null; - if (isPlainObject(data.user_stats)) return data.user_stats; - if (isPlainObject(data.userStats)) return data.userStats; - return null; - } - - function resolveExamIndex(data) { - if (!data || typeof data !== 'object') return null; - if (Array.isArray(data.exam_index)) return data.exam_index; - if (Array.isArray(data.examIndex)) return data.examIndex; - return null; - } - - function resolveStorageVersion(data) { - if (!data || typeof data !== 'object') return null; - if (data.storage_version != null) return data.storage_version; - if (data.storageVersion != null) return data.storageVersion; - return null; - } - - /** - * Canonical dual-schema payload so any legacy restore path can read snake or camel keys. - */ - function normalizePayload(data = {}) { - const source = isPlainObject(data) ? data : {}; - const records = resolvePracticeRecords(source); - const stats = resolveUserStats(source); - const examIndex = resolveExamIndex(source); - const storageVersion = resolveStorageVersion(source); - const payload = { ...source }; - - if (records) { - payload.practice_records = records; - payload.practiceRecords = records; - } - if (stats) { - payload.user_stats = stats; - payload.userStats = stats; - } - if (examIndex) { - payload.exam_index = examIndex; - payload.examIndex = examIndex; - } - if (storageVersion != null) { - payload.storage_version = storageVersion; - payload.storageVersion = storageVersion; - } - return payload; - } - - async function captureSnapshot(extra = {}) { - let practiceRecords = []; - let userStats = null; - - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') { - const listed = await global.PracticeRecordAPI.list(); - practiceRecords = Array.isArray(listed) ? listed : []; - } - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') { - userStats = await global.PracticeRecordAPI.readStats(); - } - - const examIndex = await readMeta('exam_index', []); - const storageVersion = await readMeta('storage_version', null); - - return normalizePayload({ - practice_records: practiceRecords, - user_stats: userStats, - exam_index: Array.isArray(examIndex) ? examIndex : [], - storage_version: storageVersion, - ...(isPlainObject(extra) ? extra : {}) - }); - } - - async function list(options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.list === 'function') { - const backups = await repo.list(options); - return Array.isArray(backups) ? backups : []; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - const backups = await storageFacade.get('manual_backups', []); - return Array.isArray(backups) ? backups : []; - } - throw new Error('BackupAPI.list: backup repository not ready'); - } - - async function getById(id, options = {}) { - if (!id) return null; - const repo = getBackupRepo(); - if (repo && typeof repo.getById === 'function') { - return await repo.getById(id, options); - } - const backups = await list(options); - return backups.find((item) => item && String(item.id) === String(id)) || null; - } - - async function add(backup, options = {}) { - const repo = getBackupRepo(); - const normalizedData = normalizePayload(backup && backup.data ? backup.data : {}); - const entry = { - ...(backup && typeof backup === 'object' ? backup : {}), - id: (backup && backup.id) || `backup_${Date.now()}`, - timestamp: (backup && backup.timestamp) || new Date().toISOString(), - type: (backup && backup.type) || 'manual', - version: (backup && backup.version) || DEFAULT_VERSION, - data: normalizedData - }; - entry.size = entry.size || JSON.stringify(entry.data).length; - - if (repo && typeof repo.add === 'function') { - return await repo.add(entry, options); - } - - // Fallback: raw storage (tests / early boot) - const storageFacade = getStorageFacade(); - if (storageFacade) { - const backups = await storageFacade.get('manual_backups', []); - const list = Array.isArray(backups) ? backups.slice() : []; - list.unshift(entry); - const max = options.maxBackups || DEFAULT_MAX_BACKUPS; - while (list.length > max) { - list.pop(); - } - await storageFacade.set('manual_backups', list); - return entry; - } - - throw new Error('BackupAPI.add: backup repository not ready'); - } - - async function create(options = {}) { - const { - id = null, - type = 'manual', - data = null, - extra = null, - version = DEFAULT_VERSION - } = options; - - const snapshot = data != null - ? normalizePayload(data) - : await captureSnapshot(extra || {}); - - const backupId = id || `backup_${Date.now()}`; - const entry = await add({ - id: backupId, - timestamp: new Date().toISOString(), - type, - version, - data: snapshot - }); - - return entry && entry.id ? entry.id : backupId; - } - - async function restorePayload(data, options = {}) { - const payload = normalizePayload(data || {}); - const records = resolvePracticeRecords(payload); - const stats = resolveUserStats(payload); - const examIndex = resolveExamIndex(payload); - const storageVersion = resolveStorageVersion(payload); - const restoreRecords = options.restoreRecords !== false; - const restoreExamIndex = options.restoreExamIndex !== false; - const restoreStorageVersion = options.restoreStorageVersion !== false; - - if (restoreRecords && records != null) { - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.restoreRecords === 'function') { - await global.PracticeRecordAPI.restoreRecords(records, { - stats: isPlainObject(stats) ? stats : null, - updateStats: true - }); - } else { - throw new Error('BackupAPI.restore: PracticeRecordAPI.restoreRecords not ready'); - } - } else if (isPlainObject(stats) && global.PracticeRecordAPI && typeof global.PracticeRecordAPI.resetStats === 'function') { - await global.PracticeRecordAPI.resetStats(stats); - } - - if (restoreExamIndex && examIndex) { - await writeMeta('exam_index', examIndex); - } - - if (restoreStorageVersion && storageVersion != null) { - await writeMeta('storage_version', storageVersion); - } - - // Optional system settings (DataIntegrityManager snapshots) - if (isPlainObject(payload.system_settings)) { - const repos = getRepositories(); - if (repos && repos.settings && typeof repos.settings.getAll === 'function') { - const current = await repos.settings.getAll(); - await repos.settings.saveAll({ ...current, ...payload.system_settings }); - } - } - - return { - restoredRecords: records != null, - restoredStats: isPlainObject(stats), - restoredExamIndex: Boolean(restoreExamIndex && examIndex), - restoredStorageVersion: Boolean(restoreStorageVersion && storageVersion != null) - }; - } - - async function restore(backupId, options = {}) { - if (!backupId) { - throw new Error('BackupAPI.restore: invalid backup id'); - } - const backup = await getById(backupId); - if (!backup) { - throw new Error(`BackupAPI.restore: backup ${backupId} not found`); - } - const result = await restorePayload(backup.data || {}, options); - return { backup, ...result }; - } - - async function clear(options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.clear === 'function') { - await repo.clear(options); - return true; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set('manual_backups', []); - return true; - } - throw new Error('BackupAPI.clear: backup repository not ready'); - } - - async function remove(id, options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.delete === 'function') { - return await repo.delete(id, options); - } - const backups = await list(); - const next = backups.filter((item) => item && String(item.id) !== String(id)); - if (next.length === backups.length) return false; - if (repo && typeof repo.saveAll === 'function') { - await repo.saveAll(next, options); - return true; - } - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set('manual_backups', next); - return true; - } - return false; - } - - async function prune(limit, options = {}) { - const repo = getBackupRepo(); - if (repo && typeof repo.prune === 'function') { - return await repo.prune(limit, options); - } - const max = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_MAX_BACKUPS; - const backups = await list(); - if (backups.length <= max) return backups.length; - const next = backups.slice(0, max); - if (repo && typeof repo.saveAll === 'function') { - await repo.saveAll(next, options); - } else { - const storageFacade = getStorageFacade(); - if (storageFacade) { - await storageFacade.set('manual_backups', next); - } - } - return next.length; - } - - global.BackupAPI = { - __stable: true, - version: DEFAULT_VERSION, - list, - getById, - add, - create, - captureSnapshot, - normalizePayload, - restore, - restorePayload, - clear, - remove, - prune, - resolvePracticeRecords, - resolveUserStats, - resolveExamIndex, - resolveStorageVersion - }; -})(typeof window !== 'undefined' ? window : globalThis); - - -/* ===== js/core/externalBackupService.js ===== */ -/** - * External disk backup via File System Access API. - * Browser-internal backups (manual_backups) cannot survive site-data clears; - * this service writes JSON into a user-chosen local folder. - * - * Policy: - * - Silent write only when a directory handle already has granted permission. - * - Daily reminder at most once per calendar day (permission / bind / stale write). - * - Download export is never auto-triggered; only after explicit user click. - */ -(function initExternalBackupService(global) { - 'use strict'; - - if (global.ExternalBackupService && global.ExternalBackupService.__stable === true) { - return; - } - - var META_KEY = 'exam_system_external_backup_meta'; - var DB_NAME = 'ExamSystemExternalBackup'; - var DB_VERSION = 1; - var STORE_NAME = 'handles'; - var HANDLE_KEY = 'backup_directory'; - var LATEST_FILENAME = 'practice-backup-latest.json'; - var DAY_MS = 24 * 60 * 60 * 1000; - var STALE_WRITE_MS = DAY_MS; - var REMIND_BANNER_ID = 'external-backup-remind-banner'; - var VERSION = '0.6.2-fix'; - - var state = { - ready: false, - readyPromise: null, - directoryHandle: null, - meta: null, - dirty: false, - writing: false, - lastSnapshotHash: null, - silentFlushTimer: null - }; - - function nowIso() { - return new Date().toISOString(); - } - - function dayKey(date) { - var d = date instanceof Date ? date : new Date(date || Date.now()); - if (Number.isNaN(d.getTime())) { - d = new Date(); - } - var y = d.getFullYear(); - var m = String(d.getMonth() + 1).padStart(2, '0'); - var day = String(d.getDate()).padStart(2, '0'); - return y + '-' + m + '-' + day; - } - - function isPlainObject(value) { - return value && typeof value === 'object' && !Array.isArray(value); - } - - function notify(message, type) { - if (typeof global.showMessage === 'function') { - global.showMessage(message, type || 'info'); - } - } - - function defaultMeta() { - return { - enabled: false, - directoryName: null, - lastWriteAt: null, - lastWriteOk: false, - lastWriteError: null, - lastRemindDay: null, - lastPermissionOk: false, - lastRestorePromptDay: null, - recordCountAtLastWrite: 0, - createdAt: nowIso(), - updatedAt: nowIso() - }; - } - - function readMeta() { - try { - var raw = global.localStorage && global.localStorage.getItem(META_KEY); - if (!raw) { - return defaultMeta(); - } - var parsed = JSON.parse(raw); - return Object.assign(defaultMeta(), isPlainObject(parsed) ? parsed : {}); - } catch (_) { - return defaultMeta(); - } - } - - function writeMeta(patch) { - var next = Object.assign({}, state.meta || readMeta(), isPlainObject(patch) ? patch : {}, { - updatedAt: nowIso() - }); - state.meta = next; - try { - if (global.localStorage) { - global.localStorage.setItem(META_KEY, JSON.stringify(next)); - } - } catch (error) { - console.warn('[ExternalBackup] meta write failed:', error); + const matchCore = global.AnswerMatchCore; + if (matchCore && typeof matchCore.compareAnswers === 'function') { + return matchCore.compareAnswers(userAnswer, correctAnswer) === true; } - dispatchStatus(); - return next; - } - - function dispatchStatus() { - try { - global.dispatchEvent(new CustomEvent('external-backup-status', { - detail: getStatus() - })); - } catch (_) { /* ignore */ } - } - - function supportsFileSystemAccess() { - return !!( - global.showDirectoryPicker && - typeof global.showDirectoryPicker === 'function' && - global.isSecureContext !== false - ); - } - - function supportsFilePickerRead() { - return !!(global.showOpenFilePicker && typeof global.showOpenFilePicker === 'function'); + return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase(); } - function openHandleDb() { - return new Promise(function (resolve, reject) { - if (!global.indexedDB) { - reject(new Error('IndexedDB unavailable')); + function mergeReplayMapFirstWins() { + const merged = {}; + Array.prototype.slice.call(arguments).forEach((source) => { + if (!source || typeof source !== 'object' || Array.isArray(source)) { return; } - var request = global.indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = function () { - reject(request.error || new Error('Failed to open external backup DB')); - }; - request.onupgradeneeded = function (event) { - var db = event.target.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); + const normalized = normalizeReplayMap(source); + Object.entries(normalized).forEach(([key, value]) => { + if (!Object.prototype.hasOwnProperty.call(merged, key)) { + merged[key] = value; } - }; - request.onsuccess = function () { - resolve(request.result); - }; - }); - } - - function idbRequest(request) { - return new Promise(function (resolve, reject) { - request.onsuccess = function () { resolve(request.result); }; - request.onerror = function () { reject(request.error); }; + }); }); + return merged; } - async function saveDirectoryHandle(handle) { - var db = await openHandleDb(); - try { - var tx = db.transaction(STORE_NAME, 'readwrite'); - var store = tx.objectStore(STORE_NAME); - await idbRequest(store.put(handle, HANDLE_KEY)); - } finally { - try { db.close(); } catch (_) { /* ignore */ } - } - } - - async function loadDirectoryHandle() { - var db = await openHandleDb(); - try { - var tx = db.transaction(STORE_NAME, 'readonly'); - var store = tx.objectStore(STORE_NAME); - return await idbRequest(store.get(HANDLE_KEY)); - } finally { - try { db.close(); } catch (_) { /* ignore */ } - } - } - - async function clearDirectoryHandle() { - var db = await openHandleDb(); - try { - var tx = db.transaction(STORE_NAME, 'readwrite'); - var store = tx.objectStore(STORE_NAME); - await idbRequest(store.delete(HANDLE_KEY)); - } finally { - try { db.close(); } catch (_) { /* ignore */ } - } - } - - async function queryHandlePermission(handle, mode) { - if (!handle) { - return 'denied'; - } - try { - if (typeof handle.queryPermission === 'function') { - return await handle.queryPermission({ mode: mode || 'readwrite' }); - } - } catch (_) { /* ignore */ } - return 'prompt'; - } - - async function requestHandlePermission(handle, mode) { - if (!handle) { - return 'denied'; - } - try { - if (typeof handle.requestPermission === 'function') { - return await handle.requestPermission({ mode: mode || 'readwrite' }); - } - } catch (_) { /* ignore */ } - // Some Chromium builds treat existing handles as usable without requestPermission. - return await queryHandlePermission(handle, mode); - } - - async function ensurePermission(handle, interactive) { - if (!handle) { - return false; - } - var current = await queryHandlePermission(handle, 'readwrite'); - if (current === 'granted') { - writeMeta({ lastPermissionOk: true }); - return true; - } - if (!interactive) { - writeMeta({ lastPermissionOk: false }); - return false; - } - var next = await requestHandlePermission(handle, 'readwrite'); - var ok = next === 'granted'; - writeMeta({ lastPermissionOk: ok }); - return ok; - } - - async function requestPersistentStorage() { - try { - if (!global.navigator || !global.navigator.storage || typeof global.navigator.storage.persist !== 'function') { - return false; - } - var already = typeof global.navigator.storage.persisted === 'function' - ? await global.navigator.storage.persisted() - : false; - if (already) { - return true; - } - return await global.navigator.storage.persist(); - } catch (error) { - console.warn('[ExternalBackup] persist() failed:', error); - return false; - } - } - - async function captureSnapshot() { - if (global.BackupAPI && typeof global.BackupAPI.captureSnapshot === 'function') { - var snapshot = await global.BackupAPI.captureSnapshot(); - return global.BackupAPI.normalizePayload - ? global.BackupAPI.normalizePayload(snapshot) - : snapshot; - } - - var practiceRecords = []; - var userStats = null; - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') { - var listed = await global.PracticeRecordAPI.list(); - practiceRecords = Array.isArray(listed) ? listed : []; - } - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') { - userStats = await global.PracticeRecordAPI.readStats(); - } - - var examIndex = []; - var storageVersion = null; - try { - if (global.storage && typeof global.storage.get === 'function') { - examIndex = await global.storage.get('exam_index', []); - storageVersion = await global.storage.get('storage_version', null); - } - } catch (_) { /* ignore */ } - - return { - practice_records: practiceRecords, - practiceRecords: practiceRecords, - user_stats: userStats, - userStats: userStats, - exam_index: Array.isArray(examIndex) ? examIndex : [], - examIndex: Array.isArray(examIndex) ? examIndex : [], - storage_version: storageVersion, - storageVersion: storageVersion - }; - } - - function buildExportDocument(snapshot) { - return { - exportDate: nowIso(), - version: VERSION, - source: 'external-backup-service', - note: 'Disk backup for IELTS Atlas. Survives browser cache clears. Import via 设置 → 导入数据.', - data: snapshot - }; - } - - function stableHash(payload) { - try { - var text = JSON.stringify(payload); - var hash = 0; - for (var i = 0; i < text.length; i += 1) { - hash = ((hash << 5) - hash) + text.charCodeAt(i); - hash |= 0; - } - return String(hash); - } catch (_) { - return String(Date.now()); - } - } - - async function writeTextFile(directoryHandle, filename, text) { - var fileHandle = await directoryHandle.getFileHandle(filename, { create: true }); - var writable = await fileHandle.createWritable(); - try { - await writable.write(text); - await writable.close(); - } catch (error) { - try { await writable.abort(); } catch (_) { /* ignore */ } - throw error; - } - } - - async function readTextFile(directoryHandle, filename) { - var fileHandle = await directoryHandle.getFileHandle(filename, { create: false }); - var file = await fileHandle.getFile(); - return await file.text(); + function buildReplayCorrectAnswerMap(entry = {}) { + const realData = isPlainObject(entry.realData) ? entry.realData : {}; + const rawData = isPlainObject(entry.rawData) ? entry.rawData : {}; + const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; + return mergeReplayMapFirstWins( + entry.correctAnswerMap, + realData.correctAnswerMap, + rawData.correctAnswerMap, + rawRealData.correctAnswerMap + ); } - async function writeToBoundDirectory(options) { - var opts = options || {}; - if (state.writing) { - return { success: false, reason: 'busy' }; - } - if (!state.directoryHandle) { - return { success: false, reason: 'unbound' }; - } - - state.writing = true; - try { - var interactive = opts.interactive === true; - var permitted = await ensurePermission(state.directoryHandle, interactive); - if (!permitted) { - writeMeta({ lastWriteOk: false, lastWriteError: 'permission_denied' }); - return { success: false, reason: 'permission_denied' }; - } - - var snapshot = await captureSnapshot(); - var doc = buildExportDocument(snapshot); - var text = JSON.stringify(doc, null, 2); - var hash = stableHash(doc.data); - - if (!opts.force && hash === state.lastSnapshotHash && state.meta && state.meta.lastWriteOk) { - return { success: true, reason: 'unchanged', skipped: true }; - } - - await writeTextFile(state.directoryHandle, LATEST_FILENAME, text); + function buildReplayResultSnapshot(entry = {}) { + const realData = isPlainObject(entry.realData) ? entry.realData : {}; + const rawData = isPlainObject(entry.rawData) ? entry.rawData : {}; + const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; + const answers = mergeReplayMapFirstWins( + entry.answers, + realData.answers, + rawData.answers, + rawRealData.answers + ); + const correctAnswerMap = buildReplayCorrectAnswerMap(entry); + const rawComparison = mergeReplayMapFirstWins( + entry.answerComparison, + realData.answerComparison, + rawData.answerComparison, + rawRealData.answerComparison + ); + const questionIds = new Set([ + ...Object.keys(answers), + ...Object.keys(correctAnswerMap), + ...Object.keys(rawComparison), + ...(Array.isArray(entry.allQuestionIds) + ? entry.allQuestionIds.map((item, index) => normalizeReplayQuestionKey(item, index)).filter(Boolean) + : []) + ]); - if (opts.datedCopy !== false) { - try { - var dated = 'practice-backup-' + dayKey(new Date()) + '.json'; - await writeTextFile(state.directoryHandle, dated, text); - } catch (datedError) { - console.warn('[ExternalBackup] dated copy failed:', datedError); - } + let correctCount = 0; + const answerComparison = {}; + questionIds.forEach((questionId) => { + const rawEntry = rawComparison[questionId]; + const comparisonEntry = isPlainObject(rawEntry) ? rawEntry : {}; + const userAnswer = Object.prototype.hasOwnProperty.call(comparisonEntry, 'userAnswer') + ? comparisonEntry.userAnswer + : (Object.prototype.hasOwnProperty.call(answers, questionId) ? answers[questionId] : ''); + const hasCanonicalCorrectAnswer = Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId); + const correctAnswer = hasCanonicalCorrectAnswer ? correctAnswerMap[questionId] : ''; + const isCorrect = hasCanonicalCorrectAnswer + ? compareAnswerValues(userAnswer, correctAnswer) + : null; + if (isCorrect) { + correctCount += 1; } - - var recordCount = Array.isArray(snapshot.practice_records) - ? snapshot.practice_records.length - : (Array.isArray(snapshot.practiceRecords) ? snapshot.practiceRecords.length : 0); - - state.lastSnapshotHash = hash; - state.dirty = false; - writeMeta({ - enabled: true, - lastWriteAt: nowIso(), - lastWriteOk: true, - lastWriteError: null, - lastPermissionOk: true, - recordCountAtLastWrite: recordCount - }); - - return { - success: true, - reason: 'written', - filename: LATEST_FILENAME, - recordCount: recordCount, - bytes: text.length - }; - } catch (error) { - console.error('[ExternalBackup] write failed:', error); - writeMeta({ - lastWriteOk: false, - lastWriteError: error && error.message ? error.message : String(error) - }); - return { - success: false, - reason: 'write_error', - error: error + answerComparison[questionId] = { + questionId, + userAnswer, + correctAnswer, + isCorrect }; - } finally { - state.writing = false; - } - } - - async function bindDirectory(options) { - if (!supportsFileSystemAccess()) { - throw new Error('当前浏览器不支持绑定本地文件夹(需要 Chrome/Edge,且非 file:// 打开)'); - } - - var handle = await global.showDirectoryPicker({ - id: 'ielts-atlas-external-backup', - mode: 'readwrite', - startIn: 'documents' }); - if (!handle) { - throw new Error('未选择文件夹'); - } - - var permitted = await ensurePermission(handle, true); - if (!permitted) { - throw new Error('未获得文件夹读写权限'); - } - - await saveDirectoryHandle(handle); - state.directoryHandle = handle; - writeMeta({ - enabled: true, - directoryName: handle.name || 'backup', - lastPermissionOk: true, - lastWriteError: null - }); + const totalQuestions = questionIds.size; + const sourceScoreInfo = isPlainObject(entry.scoreInfo) + ? entry.scoreInfo + : (isPlainObject(realData.scoreInfo) + ? realData.scoreInfo + : (isPlainObject(rawData.scoreInfo) ? rawData.scoreInfo : {})); + const scoreInfo = clonePlainObject(sourceScoreInfo) || {}; + const hasCompleteCanonicalCorrectAnswers = totalQuestions > 0 + && Array.from(questionIds).every(questionId => Object.prototype.hasOwnProperty.call(correctAnswerMap, questionId)); + scoreInfo.correct = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.correct)) + ? correctCount + : Number(scoreInfo.correct); + scoreInfo.total = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.total)) + ? totalQuestions + : Number(scoreInfo.total); + scoreInfo.totalQuestions = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.totalQuestions)) + ? scoreInfo.total + : Number(scoreInfo.totalQuestions); + const existingAccuracy = Number(scoreInfo.accuracy); + scoreInfo.accuracy = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(existingAccuracy) + ? (scoreInfo.totalQuestions > 0 ? scoreInfo.correct / scoreInfo.totalQuestions : 0) + : existingAccuracy; + scoreInfo.percentage = hasCompleteCanonicalCorrectAnswers || !Number.isFinite(Number(scoreInfo.percentage)) + ? Math.round(scoreInfo.accuracy * 100) + : Number(scoreInfo.percentage); + scoreInfo.answerKeyComplete = hasCompleteCanonicalCorrectAnswers; - var writeNow = !options || options.writeNow !== false; - var writeResult = null; - if (writeNow) { - writeResult = await writeToBoundDirectory({ interactive: true, force: true }); - } + const annotations = resolveAnnotationState(entry); - await requestPersistentStorage(); return { - directoryName: handle.name || 'backup', - writeResult: writeResult + answers, + correctAnswers: correctAnswerMap, + correctAnswerMap, + answerComparison, + scoreInfo, + ...annotations }; } - async function unbindDirectory() { - state.directoryHandle = null; - state.lastSnapshotHash = null; - try { - await clearDirectoryHandle(); - } catch (error) { - console.warn('[ExternalBackup] clear handle failed:', error); + function deriveCorrectMapFromDetails(details) { + if (!details || typeof details !== 'object') { + return {}; } - writeMeta({ - enabled: false, - directoryName: null, - lastPermissionOk: false, - lastWriteError: null + const map = {}; + Object.entries(details).forEach(([questionId, info]) => { + if (!info) return; + const correctAnswer = info.correctAnswer || info.answer || info.value; + if (correctAnswer != null) { + map[questionId] = normalizeAnswerValue(correctAnswer); + } }); - return true; - } - - async function restoreFromLatest(options) { - var opts = options || {}; - if (!state.directoryHandle) { - throw new Error('尚未绑定备份文件夹'); - } - var permitted = await ensurePermission(state.directoryHandle, opts.interactive !== false); - if (!permitted) { - throw new Error('需要文件夹读取权限才能恢复'); - } - - var text = await readTextFile(state.directoryHandle, LATEST_FILENAME); - var payload = JSON.parse(text); - var data = payload && payload.data ? payload.data : payload; - - if (global.BackupAPI && typeof global.BackupAPI.restorePayload === 'function') { - await global.BackupAPI.restorePayload(data, opts); - } else if (global.DataBackupManager || global.dataBackupManager) { - throw new Error('请使用设置页「导入数据」选择备份文件完成恢复'); - } else { - throw new Error('恢复 API 未就绪'); - } - - writeMeta({ lastRestorePromptDay: dayKey(new Date()) }); - return true; + return map; } - async function pickAndRestoreFile() { - if (supportsFilePickerRead()) { - var handles = await global.showOpenFilePicker({ - multiple: false, - types: [{ - description: 'IELTS Atlas backup JSON', - accept: { 'application/json': ['.json'] } - }] + function buildAnswerArray(answers, correctMap = {}) { + if (Array.isArray(answers)) { + return answers.map((answer, index) => { + const questionId = answer.questionId || `q${index + 1}`; + const userAnswer = normalizeAnswerValue(answer.answer); + const normalizedCorrect = normalizeAnswerValue(answer.correctAnswer ?? correctMap[questionId]); + return { + questionId, + answer: userAnswer, + correctAnswer: normalizedCorrect, + correct: normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : Boolean(answer.correct), + timeSpent: ensureNumber(answer.timeSpent, 0), + questionType: answer.questionType || 'unknown', + timestamp: answer.timestamp || new Date().toISOString() + }; }); - var fileHandle = handles && handles[0]; - if (!fileHandle) { - throw new Error('未选择文件'); - } - var file = await fileHandle.getFile(); - var text = await file.text(); - var payload = JSON.parse(text); - var data = payload && payload.data ? payload.data : payload; - if (global.BackupAPI && typeof global.BackupAPI.restorePayload === 'function') { - await global.BackupAPI.restorePayload(data); - return true; - } - throw new Error('恢复 API 未就绪'); } - // Fallback: reuse existing import flow - if (typeof global.importData === 'function') { - global.importData(); - return false; - } - throw new Error('当前环境不支持文件选择器,请使用「导入数据」'); - } + const answerMap = normalizeAnswerMap(answers); + const keys = new Set([ + ...Object.keys(answerMap), + ...Object.keys(correctMap || {}) + ]); - async function countPracticeRecords() { - try { - if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') { - var list = await global.PracticeRecordAPI.list(); - return Array.isArray(list) ? list.length : 0; - } - } catch (_) { /* ignore */ } - return 0; + const list = []; + keys.forEach((questionId, index) => { + const userAnswer = normalizeAnswerValue(answerMap[questionId]); + const normalizedCorrect = normalizeAnswerValue(correctMap[questionId]); + const isCorrect = normalizedCorrect ? compareAnswerValues(userAnswer, normalizedCorrect) : false; + list.push({ + questionId: questionId || `q${index + 1}`, + answer: userAnswer, + correctAnswer: normalizedCorrect, + correct: isCorrect, + timeSpent: 0, + questionType: 'unknown', + timestamp: new Date().toISOString() + }); + }); + return list; } - async function hasReadableLatestBackup() { - if (!state.directoryHandle) { - return false; - } - try { - var permitted = await ensurePermission(state.directoryHandle, false); - if (!permitted) { - return false; + function deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) { + const candidates = [ + recordData.totalQuestions, + recordData.questionCount, + recordData.question_count, + typeof recordData.questions === 'number' ? recordData.questions : null, + recordData.scoreInfo && recordData.scoreInfo.total, + recordData.scoreInfo && recordData.scoreInfo.totalQuestions, + recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.totalQuestions, + recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.total, + recordData.realData && recordData.realData.totalQuestions, + recordData.realData && recordData.realData.questionCount + ]; + for (let i = 0; i < candidates.length; i += 1) { + const numeric = Number(candidates[i]); + if (Number.isFinite(numeric) && numeric >= 0) { + return numeric; } - await state.directoryHandle.getFileHandle(LATEST_FILENAME, { create: false }); - return true; - } catch (_) { - return false; - } - } - - function buildReminder(status) { - if (!status) { - return null; - } - - if (!status.supported) { - return null; } - if (!status.bound) { - return { - level: 'info', - code: 'bind', - title: '建议绑定本地备份文件夹', - message: '练习数据只存在浏览器内,清缓存会丢失。绑定文件夹后可一键写入磁盘备份。', - primaryAction: 'bind', - primaryLabel: '绑定文件夹', - secondaryAction: null, - secondaryLabel: null - }; + if (Array.isArray(recordData.answers)) { + return recordData.answers.length; } - - if (!status.permissionGranted) { - return { - level: 'warning', - code: 'permission', - title: '本地备份需要重新授权', - message: '已绑定「' + (status.directoryName || '备份文件夹') + '」,但当前没有写入权限。', - primaryAction: 'reauth', - primaryLabel: '重新授权并写入', - secondaryAction: 'unbind', - secondaryLabel: '解除绑定' - }; + if (Array.isArray(recordData.answerList)) { + return recordData.answerList.length; } - - if (status.staleWrite || status.dirty) { - return { - level: 'info', - code: 'write', - title: '本地备份可更新', - message: status.lastWriteAt - ? ('距上次写入已超过一天或有新练习数据(上次:' + formatTime(status.lastWriteAt) + ')。') - : '尚未写入磁盘备份,建议现在写入。', - primaryAction: 'write', - primaryLabel: '立即写入备份', - secondaryAction: null, - secondaryLabel: null - }; + const detailSources = [ + recordData.answerDetails, + recordData.scoreInfo && recordData.scoreInfo.details, + recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details + ]; + for (let i = 0; i < detailSources.length; i += 1) { + const details = detailSources[i]; + if (details && typeof details === 'object') { + return Object.keys(details).length; + } } - return null; - } - - function formatTime(iso) { - if (!iso) return '—'; - try { - return new Date(iso).toLocaleString(); - } catch (_) { - return String(iso); - } + return fallbackLength || 0; } - function shouldShowDailyReminder(reminder) { - if (!reminder) { - return false; - } - var meta = state.meta || readMeta(); - var today = dayKey(new Date()); - if (meta.lastRemindDay === today) { - return false; + function deriveCorrectAnswerCount(recordData = {}, answers = []) { + const numericCandidates = [ + recordData.correctAnswers, + recordData.correctAnswersCount, + recordData.correctCount, + recordData.correct, + recordData.score, + recordData.scoreInfo && recordData.scoreInfo.correct, + recordData.scoreInfo && recordData.scoreInfo.score, + recordData.realData && recordData.realData.correctAnswersCount, + recordData.realData && recordData.realData.correctCount, + recordData.realData && recordData.realData.correct, + recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.correct, + recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.score + ]; + for (let i = 0; i < numericCandidates.length; i += 1) { + const numeric = Number(numericCandidates[i]); + if (Number.isFinite(numeric) && numeric >= 0) { + return numeric; + } } - return true; - } - - function markReminded() { - writeMeta({ lastRemindDay: dayKey(new Date()) }); - } - function removeRemindBanner() { - var el = global.document && global.document.getElementById(REMIND_BANNER_ID); - if (el && el.parentNode) { - el.parentNode.removeChild(el); + if (Array.isArray(answers) && answers.length > 0) { + return answers.reduce((sum, answer) => { + if (!answer || typeof answer !== 'object') { + return sum; + } + return (answer.correct === true || answer.isCorrect === true) ? sum + 1 : sum; + }, 0); } - } - function renderRemindBanner(reminder) { - if (!global.document || !global.document.body || !reminder) { - return; + const detailSources = [ + recordData.answerDetails, + recordData.scoreInfo && recordData.scoreInfo.details, + recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details + ]; + for (let i = 0; i < detailSources.length; i += 1) { + const details = detailSources[i]; + if (!details || typeof details !== 'object') { + continue; + } + let hasFlag = false; + let correctCount = 0; + Object.values(details).forEach((detail) => { + if (!detail || typeof detail !== 'object') { + return; + } + if (detail.isCorrect === true || detail.correct === true) { + correctCount += 1; + } + hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean'; + }); + if (hasFlag) { + return correctCount; + } } - removeRemindBanner(); - - var banner = global.document.createElement('div'); - banner.id = REMIND_BANNER_ID; - banner.className = 'external-backup-banner external-backup-banner--' + (reminder.level || 'info'); - banner.setAttribute('role', 'status'); - - var glass = global.document.createElement('div'); - glass.className = 'external-backup-banner__glass'; - - var text = global.document.createElement('div'); - text.className = 'external-backup-banner__text'; - var title = global.document.createElement('strong'); - title.textContent = reminder.title; - var msg = global.document.createElement('span'); - msg.textContent = reminder.message; - text.appendChild(title); - text.appendChild(msg); + return 0; + } - var actions = global.document.createElement('div'); - actions.className = 'external-backup-banner__actions'; - - function makeBtn(label, action, primary) { - var btn = global.document.createElement('button'); - btn.type = 'button'; - btn.className = primary - ? 'btn external-backup-banner__btn external-backup-banner__btn--primary' - : 'btn external-backup-banner__btn external-backup-banner__btn--ghost'; - btn.textContent = label; - btn.addEventListener('click', function () { - handleReminderAction(action); - }); - return btn; - } + function buildMetadata(recordData = {}, type) { + const metadata = Object.assign({}, recordData.metadata || {}); + const examId = recordData.examId; + const fallbackTitle = recordData.title || recordData.examTitle || recordData.examName || recordData.name || examId || 'Unknown Exam'; + const fallbackCategory = recordData.category || recordData.examCategory || recordData.section || recordData.mode || metadata.category || 'Unknown'; + const fallbackFrequency = recordData.frequency || metadata.frequency || 'unknown'; - if (reminder.primaryAction) { - actions.appendChild(makeBtn(reminder.primaryLabel || '确定', reminder.primaryAction, true)); + metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle; + metadata.category = metadata.category || fallbackCategory; + metadata.frequency = metadata.frequency || fallbackFrequency; + metadata.type = type; + metadata.examType = metadata.examType || type; + if (recordData.suiteSessionId && !metadata.suiteSessionId) { + metadata.suiteSessionId = recordData.suiteSessionId; } - if (reminder.secondaryAction) { - actions.appendChild(makeBtn(reminder.secondaryLabel || '取消', reminder.secondaryAction, false)); + if (recordData.practiceMode && !metadata.practiceMode) { + metadata.practiceMode = recordData.practiceMode; } + return metadata; + } - var dismiss = global.document.createElement('button'); - dismiss.type = 'button'; - dismiss.className = 'external-backup-banner__dismiss'; - dismiss.setAttribute('aria-label', '关闭提醒'); - dismiss.textContent = '×'; - dismiss.addEventListener('click', function () { - markReminded(); - removeRemindBanner(); - }); - - glass.appendChild(text); - glass.appendChild(actions); - glass.appendChild(dismiss); - banner.appendChild(glass); - global.document.body.appendChild(banner); - markReminded(); + function inferPracticeType(recordData = {}) { + const metadata = recordData.metadata || {}; + const normalized = normalizePracticeType( + recordData.type + || metadata.type + || metadata.examType + || recordData.category + || recordData.mode + || recordData.section + || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null) + ); + return normalized || 'reading'; } - async function handleReminderAction(action) { - try { - if (action === 'bind') { - var bound = await bindDirectory({ writeNow: true }); - removeRemindBanner(); - if (bound.writeResult && bound.writeResult.success) { - notify('已绑定并写入本地备份:' + (bound.directoryName || ''), 'success'); - } else { - notify('已绑定文件夹:' + (bound.directoryName || '') + ',请点击「立即写入备份」', 'info'); - } - refreshUi(); - return; + function standardizeSuiteEntries(entries) { + if (!Array.isArray(entries)) { + return []; + } + return entries.map((entry, index) => { + if (!entry || typeof entry !== 'object') { + return null; } - if (action === 'reauth' || action === 'write') { - var result = await writeToBoundDirectory({ interactive: true, force: true }); - removeRemindBanner(); - if (result.success) { - notify(result.skipped ? '备份已是最新' : '本地备份已写入', 'success'); - } else if (result.reason === 'permission_denied') { - notify('仍未获得文件夹权限,请在浏览器弹窗中允许访问', 'warning'); - } else if (result.reason === 'unbound') { - notify('尚未绑定备份文件夹', 'warning'); - } else { - notify('写入失败:' + (result.error && result.error.message ? result.error.message : result.reason), 'error'); + const answerComparisonSource = entry.answerComparison + || (entry.realData && entry.realData.answerComparison) + || (entry.rawData && entry.rawData.answerComparison) + || (entry.scoreInfo && entry.scoreInfo.details) + || null; + const entryCorrectMap = resolveRecordCorrectAnswerMap(entry, { comparison: answerComparisonSource }); + const normalizedAnswers = buildAnswerArray(entry.answers || entry.answerList || [], entryCorrectMap); + const answerMap = normalizedAnswers.reduce((map, item) => { + if (item && item.questionId) { + map[item.questionId] = item.answer || ''; } - refreshUi(); - return; - } - if (action === 'unbind') { - await unbindDirectory(); - removeRemindBanner(); - notify('已解除本地备份文件夹绑定', 'info'); - refreshUi(); - } - } catch (error) { - if (error && error.name === 'AbortError') { - notify('已取消', 'info'); + return map; + }, {}); + // 旧/导入的套题条目可能只在 entry.metadata.markedQuestions 保留标记题, + // 与顶层 standardizeRecord(见下方 resolveAnnotationState(recordData, [recordData.metadata])) + // 保持一致,将 entry.metadata 作为兜底来源传入,避免根级 markedQuestions: [] 被回放 + // 逻辑视作权威而丢弃已保存的标记题。 + const metadata = entry.metadata ? Object.assign({}, entry.metadata) : {}; + const annotations = resolveAnnotationState(entry, [entry.metadata], { preferNonEmptyArrays: true }); + metadata.markedQuestions = clonePlainObject(annotations.markedQuestions); + return { + examId: entry.examId || null, + title: entry.title || entry.examTitle || `套题第${index + 1}篇`, + category: entry.category || (entry.metadata && entry.metadata.category) || '套题', + duration: ensureNumber(entry.duration, 0), + scoreInfo: entry.scoreInfo ? clonePlainObject(entry.scoreInfo) : null, + answers: answerMap, + correctAnswerMap: entryCorrectMap, + answerComparison: clonePlainObject(answerComparisonSource) || null, + metadata, + ...annotations, + rawData: entry.rawData ? clonePlainObject(entry.rawData) : null + }; + }).filter(Boolean); + } + + function mergeAnswerSources() { + const merged = {}; + Array.prototype.slice.call(arguments).forEach((source) => { + if (!source) { return; } - console.error('[ExternalBackup] reminder action failed:', error); - notify(error && error.message ? error.message : '操作失败', 'error'); - } + const normalized = normalizeAnswerMap(source); + Object.entries(normalized).forEach(([key, value]) => { + if (value == null) { + return; + } + const trimmed = String(value).trim(); + if (!trimmed) { + return; + } + if (!Object.prototype.hasOwnProperty.call(merged, key)) { + merged[key] = trimmed; + } + }); + }); + return merged; } - function getStatus() { - var meta = state.meta || readMeta(); - var lastWriteAt = meta.lastWriteAt || null; - var lastWriteAge = lastWriteAt ? (Date.now() - new Date(lastWriteAt).getTime()) : Infinity; - var staleWrite = !lastWriteAt || !Number.isFinite(lastWriteAge) || lastWriteAge >= STALE_WRITE_MS; - var permissionGranted = !!(state.directoryHandle && meta.lastPermissionOk); - - return { - supported: supportsFileSystemAccess(), - secureContext: global.isSecureContext !== false, - bound: !!(state.directoryHandle && meta.enabled), - directoryName: meta.directoryName || null, - permissionGranted: permissionGranted, - lastWriteAt: lastWriteAt, - lastWriteOk: !!meta.lastWriteOk, - lastWriteError: meta.lastWriteError || null, - lastWriteAgeMs: Number.isFinite(lastWriteAge) ? lastWriteAge : null, - staleWrite: staleWrite, - dirty: !!state.dirty, - writing: !!state.writing, - recordCountAtLastWrite: meta.recordCountAtLastWrite || 0, - latestFilename: LATEST_FILENAME, - lastRemindDay: meta.lastRemindDay || null - }; + function resolveCorrectAnswerMap() { + const sources = Array.prototype.slice.call(arguments).filter((source) => isPlainObject(source)); + return mergeAnswerSources.apply(null, sources); } - async function refreshPermissionFlag() { - if (!state.directoryHandle) { - writeMeta({ lastPermissionOk: false }); - return false; + function resolveRecordCorrectAnswerMap(recordData = {}, options = {}) { + if (!isPlainObject(recordData)) { + return {}; } - var ok = await ensurePermission(state.directoryHandle, false); - return ok; + const realData = isPlainObject(recordData.realData) ? recordData.realData : {}; + const rawData = isPlainObject(recordData.rawData) ? recordData.rawData : {}; + const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {}; + const comparisonSource = options.comparison + || recordData.answerComparison + || realData.answerComparison + || rawData.answerComparison + || rawRealData.answerComparison + || null; + return resolveCorrectAnswerMap( + ...(Array.isArray(options.prioritySources) ? options.prioritySources : []), + recordData.correctAnswerMap, + realData.correctAnswerMap, + rawData.correctAnswerMap, + rawRealData.correctAnswerMap, + recordData.correctAnswers, + realData.correctAnswers, + rawData.correctAnswers, + rawRealData.correctAnswers, + deriveCorrectMapFromDetails(recordData.answerDetails), + deriveCorrectMapFromDetails(recordData.scoreInfo && recordData.scoreInfo.details), + deriveCorrectMapFromDetails(realData.scoreInfo && realData.scoreInfo.details), + deriveCorrectMapFromDetails(rawData.scoreInfo && rawData.scoreInfo.details), + deriveCorrectMapFromDetails(rawRealData.scoreInfo && rawRealData.scoreInfo.details), + ...(Array.isArray(options.detailSources) + ? options.detailSources.map((details) => deriveCorrectMapFromDetails(details)) + : []), + convertComparisonToMap(comparisonSource, 'correctAnswer') + ); } - async function maybeShowDailyReminder(options) { - var opts = options || {}; - await ensureReady(); - await refreshPermissionFlag(); - var status = getStatus(); - var reminder = buildReminder(status); - if (!reminder) { - if (opts.force) { - removeRemindBanner(); - } - return null; - } - if (opts.force || shouldShowDailyReminder(reminder)) { - if (opts.render !== false) { - renderRemindBanner(reminder); - } - return reminder; - } - return null; + function defaultGenerateRecordId() { + return `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } - async function maybePromptEmptyStoreRecovery() { - await ensureReady(); - var count = await countPracticeRecords(); - if (count > 0) { - return false; - } - var readable = await hasReadableLatestBackup(); - if (!readable) { - return false; - } + function standardizeRecord(recordData, options = {}) { + const now = new Date().toISOString(); + const type = inferPracticeType(recordData); + const recordDate = resolveRecordDate(recordData, now); + const resolvedExamId = inferExamId(recordData); + const recordId = firstStringCandidate( + recordData.id, + recordData.recordId, + recordData.record_id, + recordData.practiceId, + recordData.practice_id, + recordData.uuid + ); + const metadata = buildMetadata( + Object.assign({}, recordData, { examId: resolvedExamId }), + type + ); + const comparisonSource = recordData.answerComparison + || (recordData.realData && recordData.realData.answerComparison) + || null; + let normalizedCorrectMap = resolveRecordCorrectAnswerMap(recordData, { comparison: comparisonSource }); - var meta = state.meta || readMeta(); - var today = dayKey(new Date()); - if (meta.lastRestorePromptDay === today) { - return false; + const normalizedAnswers = buildAnswerArray(recordData.answers || recordData.answerList || [], normalizedCorrectMap); + let answerMap = normalizedAnswers.reduce((map, item) => { + if (item && item.questionId) { + map[item.questionId] = item.answer || ''; + } + return map; + }, {}); + if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) { + answerMap = convertComparisonToMap(comparisonSource, 'userAnswer'); } - writeMeta({ lastRestorePromptDay: today }); - var dirName = (state.meta && state.meta.directoryName) || '备份文件夹'; - var ok = false; - try { - ok = global.confirm( - '检测到浏览器内练习记录为空,但本地备份文件夹「' + dirName + - '」中有 ' + LATEST_FILENAME + '。是否立即恢复?' - ); - } catch (_) { - ok = false; - } - if (!ok) { - return false; + const derivedTotalQuestions = deriveTotalQuestionCount(recordData, normalizedAnswers.length); + const derivedCorrectAnswers = deriveCorrectAnswerCount(recordData, normalizedAnswers); + const totalQuestions = ensureNumber(recordData.totalQuestions, derivedTotalQuestions); + const correctAnswers = ensureNumber(recordData.correctAnswers, derivedCorrectAnswers); + let accuracy = ensureNumber( + recordData.accuracy + ?? (recordData.realData && recordData.realData.accuracy) + ?? (recordData.scoreInfo && recordData.scoreInfo.accuracy) + ?? (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.accuracy) + ?? recordData.percentage + ?? (recordData.scoreInfo && recordData.scoreInfo.percentage), + totalQuestions > 0 ? correctAnswers / totalQuestions : 0 + ); + if (accuracy > 1 && accuracy <= 100) { + accuracy = accuracy / 100; } - try { - await restoreFromLatest({ interactive: true }); - notify('已从本地备份文件夹恢复数据', 'success'); - try { - if (typeof global.updateOverview === 'function') { - global.updateOverview(); - } - } catch (_) { /* ignore */ } - try { - global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', { - detail: { source: 'external-backup-restore' } - })); - } catch (_) { /* ignore */ } - return true; - } catch (error) { - console.error('[ExternalBackup] restore failed:', error); - notify('恢复失败:' + (error && error.message ? error.message : error), 'error'); - return false; + if (!Number.isFinite(accuracy) || accuracy < 0) { + accuracy = 0; + } else if (accuracy > 1) { + accuracy = 1; } - } - function markDirty() { - state.dirty = true; - dispatchStatus(); - scheduleSilentFlush(); - } + const detailSource = recordData.answerDetails + || (recordData.scoreInfo && recordData.scoreInfo.details) + || (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details) + || (comparisonSource ? convertComparisonToDetails(comparisonSource) : null) + || buildAnswerDetails(answerMap, normalizedCorrectMap); - /** - * When folder permission is already granted, write silently after data changes. - * Never auto-downloads; never prompts for permission here. - */ - function scheduleSilentFlush() { - if (state.silentFlushTimer) { - global.clearTimeout(state.silentFlushTimer); - } - state.silentFlushTimer = global.setTimeout(function () { - state.silentFlushTimer = null; - flushSilentlyIfPermitted().catch(function (error) { - console.warn('[ExternalBackup] silent flush failed:', error); - }); - }, 8000); - } + const startTime = firstDateCandidate( + recordData.startTime, + recordData.start_time, + recordData.startedAt, + recordData.createdAt, + recordData.timestamp, + recordData.date, + recordDate + ) || recordDate; + const endTime = firstDateCandidate( + recordData.endTime, + recordData.end_time, + recordData.completedAt, + recordData.finishedAt, + recordData.finishTime, + recordDate + ) || recordDate; + const resolvedTitle = recordData.title + || metadata.examTitle + || metadata.title + || recordData.examTitle + || recordData.examName + || recordData.name + || recordData.examId + || '未命名练习'; + const normalizedSuiteEntries = standardizeSuiteEntries(recordData.suiteEntries || []); + const normalizedComparison = comparisonSource && typeof comparisonSource === 'object' + ? clonePlainObject(comparisonSource) + : null; + const realDataCorrectAnswers = clonePlainObject(normalizedCorrectMap || {}); + const annotations = resolveAnnotationState(recordData, [recordData.metadata]); + metadata.markedQuestions = clonePlainObject(annotations.markedQuestions); + const generateRecordId = typeof options.generateRecordId === 'function' + ? options.generateRecordId + : defaultGenerateRecordId; - async function flushSilentlyIfPermitted() { - await ensureReady(); - if (!state.directoryHandle || !state.dirty || state.writing) { - return { success: false, reason: 'skip' }; - } - var permitted = await ensurePermission(state.directoryHandle, false); - if (!permitted) { - // Permission missing: daily banner handles re-auth; do not prompt here. - return { success: false, reason: 'permission_denied' }; - } - return writeToBoundDirectory({ interactive: false, force: false }); + return { + id: recordId || generateRecordId(), + examId: resolvedExamId, + sessionId: recordData.sessionId || recordData.sessionID || null, + title: resolvedTitle, + type, + startTime, + endTime, + duration: resolveDurationSeconds(recordData, startTime, endTime), + date: recordDate, + status: recordData.status || 'completed', + score: ensureNumber(recordData.score ?? recordData.finalScore ?? (recordData.realData && recordData.realData.score), correctAnswers), + totalQuestions, + correctAnswers, + accuracy, + answers: normalizedAnswers, + answerDetails: detailSource || null, + correctAnswerMap: normalizedCorrectMap || {}, + questionTypePerformance: recordData.questionTypePerformance || {}, + metadata, + frequency: recordData.frequency || metadata.frequency || null, + suiteMode: Boolean(recordData.suiteMode || ((recordData.frequency || metadata.frequency || '').toLowerCase() === 'suite')), + suiteSessionId: recordData.suiteSessionId || (metadata && metadata.suiteSessionId) || null, + suiteEntries: normalizedSuiteEntries, + ...annotations, + scoreInfo: recordData.scoreInfo + ? Object.assign({}, recordData.scoreInfo, { + details: recordData.scoreInfo.details || detailSource || null + }) + : (detailSource ? { details: detailSource } : null), + realData: Object.assign({}, recordData.realData || {}, { + answers: (recordData.realData && recordData.realData.answers) || answerMap, + correctAnswers: realDataCorrectAnswers, + correctAnswerMap: clonePlainObject(normalizedCorrectMap || {}), + scoreInfo: Object.assign({}, (recordData.realData && recordData.realData.scoreInfo) || {}, { + details: (recordData.realData && recordData.realData.scoreInfo && recordData.realData.scoreInfo.details) || detailSource || null + }), + answerComparison: (recordData.realData && recordData.realData.answerComparison) + ? clonePlainObject(recordData.realData.answerComparison) + : (normalizedComparison || null), + ...clonePlainObject(annotations) + }), + answerComparison: normalizedComparison, + version: options.currentVersion || recordData.version || '0.6.2-fix', + createdAt: firstDateCandidate(recordData.createdAt, recordData.startTime, recordData.start_time, recordDate) || now, + updatedAt: firstDateCandidate(recordData.updatedAt, recordData.endTime, recordData.end_time, now) || now + }; } - function refreshUi() { - try { - if (typeof global.refreshExternalBackupPanel === 'function') { - global.refreshExternalBackupPanel(); + function extractEnvelopeData(envelope) { + const candidates = [envelope.data, envelope.payload, envelope.detail]; + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; + if (isPlainObject(candidate)) return candidate; + if (typeof candidate === 'string') { + const parsed = safeParseJson(candidate); + if (isPlainObject(parsed)) return parsed; } - } catch (_) { /* ignore */ } - dispatchStatus(); - } - - function formatStatusText(status) { - if (!status.supported) { - return '当前环境不支持文件夹绑定(请用 Chrome/Edge 通过 http(s) 打开;file:// 下请用「导出到下载」)。'; - } - if (!status.bound) { - return '未绑定本地备份文件夹。绑定后可一键写入磁盘,避免清缓存丢数据。'; } - var parts = []; - parts.push('已绑定:' + (status.directoryName || '文件夹')); - if (!status.permissionGranted) { - parts.push('权限失效,需重新授权'); - } else if (status.lastWriteAt) { - parts.push('上次写入 ' + formatTime(status.lastWriteAt)); - if (status.lastWriteOk === false) { - parts.push('最近一次写入失败'); + if (Array.isArray(envelope.args)) { + for (let i = 0; i < envelope.args.length; i += 1) { + const candidate = envelope.args[i]; + if (isPlainObject(candidate)) return candidate; } - } else { - parts.push('尚未写入'); - } - if (status.dirty) { - parts.push('有未备份的新数据'); - } - return parts.join(' · '); - } - - function formatEntryLabel(status) { - if (!status.supported) { - return '📁 本地磁盘备份'; - } - if (!status.bound) { - return '📁 本地磁盘备份'; - } - if (!status.permissionGranted) { - return '📁 本地备份 · 需授权'; } - if (status.staleWrite || status.dirty) { - return '📁 本地备份 · 待更新'; - } - return '📁 本地备份 · 已就绪'; - } - - var ENTRY_ID = 'external-backup-entry-btn'; - var MODAL_ID = 'external-backup-modal'; - var modalBound = false; - - function getModal() { - return global.document ? global.document.getElementById(MODAL_ID) : null; + const fallback = {}; + const baseKeys = new Set(['type', 'messageType', 'action', 'event', 'data', 'payload', 'detail', 'args', 'source', 'message', 'messageData']); + let hasFallback = false; + Object.keys(envelope || {}).forEach((key) => { + if (!baseKeys.has(key)) { + fallback[key] = envelope[key]; + hasFallback = true; + } + }); + return hasFallback ? fallback : {}; } - function openModal() { - ensureModalDom(); - var modal = getModal(); - if (modal) { - modal.classList.add('show'); - refreshExternalBackupPanel(); + function normalizeMessageType(value) { + if (typeof value !== 'string') { + return ''; } - } - - function closeModal() { - var modal = getModal(); - if (modal) { - modal.classList.remove('show'); + const normalized = value.trim(); + if (!normalized) { + return ''; } + return MESSAGE_TYPE_ALIASES[normalized] || normalized.toUpperCase(); } - function makeActionButton(id, label) { - var btn = global.document.createElement('button'); - btn.type = 'button'; - btn.className = 'btn data-mgmt-btn'; - btn.id = id; - btn.textContent = label; - return btn; - } - - function ensureEntryButton() { - var panel = global.document && global.document.querySelector('#settings-view .data-management-panel'); - if (!panel) { - return null; - } - var entry = global.document.getElementById(ENTRY_ID); - if (entry) { - return entry; - } - - var actions = panel.querySelector('.hero-settings-actions'); - if (!actions) { + function normalizeMessage(rawEnvelope, depth = 0) { + if (depth > 2) { return null; } - entry = global.document.createElement('button'); - entry.type = 'button'; - entry.className = 'btn data-mgmt-btn'; - entry.id = ENTRY_ID; - entry.textContent = '📁 本地磁盘备份'; - - // Prefer leading position so the recommended action is easy to find. - if (actions.firstChild) { - actions.insertBefore(entry, actions.firstChild); - } else { - actions.appendChild(entry); + let envelope = rawEnvelope; + if (typeof envelope === 'string') { + envelope = safeParseJson(envelope); } - return entry; - } - - function ensureModalDom() { - if (!global.document || !global.document.body) { + if (!isPlainObject(envelope)) { return null; } - var modal = getModal(); - if (modal) { - if (!modalBound) { - bindModalEvents(modal); - } - return modal; - } - - modal = global.document.createElement('div'); - modal.id = MODAL_ID; - modal.className = 'theme-modal external-backup-modal shui-secondary-modal shui-secondary-modal--sm'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('aria-labelledby', 'external-backup-title'); - - var content = global.document.createElement('div'); - content.className = 'theme-modal-content external-backup-modal__content shui-secondary-modal__content'; - - var header = global.document.createElement('div'); - header.className = 'theme-modal-header external-backup-modal__header shui-secondary-modal__header'; - - var titleGroup = global.document.createElement('div'); - titleGroup.className = 'external-backup-modal__title-group shui-secondary-modal__title-group'; - - var eyebrow = global.document.createElement('div'); - eyebrow.className = 'external-backup-modal__eyebrow shui-secondary-modal__eyebrow'; - eyebrow.textContent = 'DISK BACKUP'; - - var title = global.document.createElement('h3'); - title.id = 'external-backup-title'; - title.textContent = '本地磁盘备份'; - - titleGroup.appendChild(eyebrow); - titleGroup.appendChild(title); - - var closeBtn = global.document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'theme-modal-close'; - closeBtn.setAttribute('aria-label', '关闭'); - closeBtn.innerHTML = '×'; - - header.appendChild(titleGroup); - header.appendChild(closeBtn); - - var body = global.document.createElement('div'); - body.className = 'theme-modal-body external-backup-modal__body shui-secondary-modal__body'; - - var host = global.document.createElement('div'); - host.id = 'external-backup-panel'; - host.className = 'external-backup-panel external-backup-panel--modal'; - - var desc = global.document.createElement('p'); - desc.className = 'external-backup-panel__desc'; - desc.textContent = '绑定本地文件夹后,可把练习数据写入磁盘 JSON。清浏览器缓存不会删除该文件夹中的文件;已授权时可在后台静默更新;每天最多提醒一次,且不会自动下载。'; - - var statusCard = global.document.createElement('div'); - statusCard.className = 'external-backup-status-card'; - - var statusLabel = global.document.createElement('div'); - statusLabel.className = 'external-backup-status-card__label'; - statusLabel.textContent = '当前状态'; - - var status = global.document.createElement('div'); - status.id = 'external-backup-status'; - status.className = 'external-backup-panel__status'; - status.textContent = '状态加载中…'; - - statusCard.appendChild(statusLabel); - statusCard.appendChild(status); - - var tips = global.document.createElement('ul'); - tips.className = 'external-backup-panel__tips'; - [ - '推荐使用 Chrome / Edge,通过 http(s) 或 localhost 打开', - 'file:// 环境通常无法绑定文件夹,请改用「导出到下载」', - '应用内备份只防导入误操作,防不了清缓存' - ].forEach(function (line) { - var li = global.document.createElement('li'); - li.textContent = line; - tips.appendChild(li); - }); - - var actions = global.document.createElement('div'); - actions.className = 'external-backup-panel__actions'; - - var bindBtn = makeActionButton('external-backup-bind-btn', '📁 绑定备份文件夹'); - var writeBtn = makeActionButton('external-backup-write-btn', '💾 立即写入备份'); - var restoreBtn = makeActionButton('external-backup-restore-btn', '♻️ 从文件夹恢复'); - var unbindBtn = makeActionButton('external-backup-unbind-btn', '🔓 解除绑定'); - unbindBtn.classList.add('external-backup-btn--ghost'); - - actions.appendChild(bindBtn); - actions.appendChild(writeBtn); - actions.appendChild(restoreBtn); - actions.appendChild(unbindBtn); - - host.appendChild(desc); - host.appendChild(statusCard); - host.appendChild(tips); - host.appendChild(actions); - body.appendChild(host); - - content.appendChild(header); - content.appendChild(body); - modal.appendChild(content); - global.document.body.appendChild(modal); - - bindBtn.addEventListener('click', async function () { - try { - await ensureReady(); - var result = await bindDirectory({ writeNow: true }); - if (result.writeResult && result.writeResult.success) { - notify('已绑定并写入:' + result.directoryName, 'success'); - } else { - notify('已绑定:' + result.directoryName, 'success'); - } - } catch (error) { - if (error && error.name === 'AbortError') { - notify('已取消选择文件夹', 'info'); - } else { - notify(error && error.message ? error.message : '绑定失败', 'error'); - } - } finally { - refreshExternalBackupPanel(); - } - }); - - writeBtn.addEventListener('click', async function () { - try { - await ensureReady(); - var result = await writeToBoundDirectory({ interactive: true, force: true }); - if (result.success) { - notify(result.skipped ? '备份内容无变化' : ('已写入 ' + (result.filename || LATEST_FILENAME)), 'success'); - } else if (result.reason === 'unbound') { - notify('请先绑定备份文件夹', 'warning'); - } else if (result.reason === 'permission_denied') { - notify('需要允许文件夹访问权限', 'warning'); - } else { - notify('写入失败:' + (result.error && result.error.message ? result.error.message : result.reason), 'error'); - } - } catch (error) { - notify(error && error.message ? error.message : '写入失败', 'error'); - } finally { - refreshExternalBackupPanel(); - } - }); - - restoreBtn.addEventListener('click', async function () { - try { - await ensureReady(); - var statusNow = getStatus(); - if (!statusNow.bound) { - await pickAndRestoreFile(); - notify('已从文件恢复(或已打开导入流程)', 'success'); - return; - } - var ok = true; - try { - ok = global.confirm('将用文件夹中的 ' + LATEST_FILENAME + ' 覆盖/恢复练习数据,是否继续?'); - } catch (_) { /* ignore */ } - if (!ok) { - return; - } - await restoreFromLatest({ interactive: true }); - notify('已从本地备份文件夹恢复', 'success'); - try { - if (typeof global.updateOverview === 'function') { - global.updateOverview(); - } - } catch (_) { /* ignore */ } - } catch (error) { - if (error && error.name === 'AbortError') { - notify('已取消', 'info'); - } else { - notify(error && error.message ? error.message : '恢复失败', 'error'); - } - } finally { - refreshExternalBackupPanel(); - } - }); + const rawType = envelope.type || envelope.messageType || envelope.action || envelope.event || ''; + const type = normalizeMessageType(rawType); - unbindBtn.addEventListener('click', async function () { - try { - await ensureReady(); - var ok = true; - try { - ok = global.confirm('解除绑定后将不再写入该文件夹(磁盘上的备份文件仍保留)。确定?'); - } catch (_) { /* ignore */ } - if (!ok) { - return; - } - await unbindDirectory(); - notify('已解除绑定', 'info'); - } catch (error) { - notify(error && error.message ? error.message : '解除绑定失败', 'error'); - } finally { - refreshExternalBackupPanel(); + if (!type) { + const nested = envelope.message || envelope.messageData; + if (nested) { + return normalizeMessage(nested, depth + 1); } - }); - - bindModalEvents(modal); - return modal; - } - - function bindModalEvents(modal) { - if (!modal || modalBound) { - return; - } - modalBound = true; - - var closeBtn = modal.querySelector('.theme-modal-close'); - if (closeBtn) { - closeBtn.addEventListener('click', closeModal); + return null; } - modal.addEventListener('click', function (event) { - if (event.target === modal) { - closeModal(); - } - }); - global.document.addEventListener('keydown', function (event) { - if (event.key === 'Escape' && modal.classList.contains('show')) { - closeModal(); - } - }); - var entry = ensureEntryButton(); - if (entry && !entry.__externalBackupBound) { - entry.__externalBackupBound = true; - entry.addEventListener('click', function (event) { - event.preventDefault(); - openModal(); - }); - } - } + const data = extractEnvelopeData(envelope); + const sourceTag = typeof envelope.source === 'string' + ? envelope.source + : (typeof data.source === 'string' ? data.source : ''); - function ensurePanelDom() { - // Compact entry on settings page + secondary modal body. - ensureEntryButton(); - var modal = ensureModalDom(); - return modal ? modal.querySelector('#external-backup-panel') : null; + return { type, data: isPlainObject(data) ? data : {}, sourceTag, rawType: rawType || type }; } - function refreshExternalBackupPanel() { - var host = ensurePanelDom(); - var status = getStatus(); - - var entry = global.document && global.document.getElementById(ENTRY_ID); - if (entry) { - entry.textContent = formatEntryLabel(status); - entry.dataset.state = status.bound - ? (status.permissionGranted ? (status.staleWrite || status.dirty ? 'stale' : 'ok') : 'need-auth') - : (status.supported ? 'unbound' : 'unsupported'); - entry.title = formatStatusText(status); + function isPracticeCompleteType(type) { + if (!type) { + return false; } + return PRACTICE_COMPLETE_TYPES.has(type) || normalizeMessageType(type) === 'PRACTICE_COMPLETE'; + } - if (!host) { - return; - } + function buildEnvelope(type, data) { + return { + type, + data: isPlainObject(data) ? data : {} + }; + } - var statusEl = host.querySelector('#external-backup-status'); - if (statusEl) { - statusEl.textContent = formatStatusText(status); - statusEl.dataset.state = status.bound - ? (status.permissionGranted ? (status.staleWrite || status.dirty ? 'stale' : 'ok') : 'need-auth') - : (status.supported ? 'unbound' : 'unsupported'); + function deriveCategory(recordPayload = {}, examEntry = null, metadata = {}) { + if (metadata.category) { + return metadata.category; } - - var writeBtn = host.querySelector('#external-backup-write-btn'); - var unbindBtn = host.querySelector('#external-backup-unbind-btn'); - var restoreBtn = host.querySelector('#external-backup-restore-btn'); - var bindBtn = host.querySelector('#external-backup-bind-btn'); - - if (bindBtn) { - bindBtn.disabled = !status.supported; - bindBtn.textContent = status.bound ? '📁 更换备份文件夹' : '📁 绑定备份文件夹'; + if (recordPayload.category) { + return recordPayload.category; } - if (writeBtn) { - writeBtn.disabled = !status.bound || status.writing; + if (examEntry && examEntry.category) { + return examEntry.category; } - if (unbindBtn) { - unbindBtn.disabled = !status.bound; + if (recordPayload.pageType) { + return recordPayload.pageType; } - if (restoreBtn) { - restoreBtn.disabled = false; + if (recordPayload.url) { + const match = String(recordPayload.url).match(/\b(P[1-4])\b/i); + if (match) return match[1].toUpperCase(); } - } - - function onStorageSync(event) { - var key = event && event.detail ? event.detail.key : null; - if (!key || key === '*' || key === 'practice_records' || key === 'user_stats' || - String(key).indexOf('practice') !== -1 || String(key).indexOf('vocab') !== -1) { - markDirty(); + if (recordPayload.title) { + const match = String(recordPayload.title).match(/\b(P[1-4])\b/i); + if (match) return match[1].toUpperCase(); } + return 'Unknown'; } - async function ensureReady() { - if (state.ready) { - return true; - } - if (state.readyPromise) { - return state.readyPromise; - } - state.readyPromise = (async function () { - state.meta = readMeta(); - try { - var handle = await loadDirectoryHandle(); - if (handle) { - state.directoryHandle = handle; - var ok = await ensurePermission(handle, false); - writeMeta({ - enabled: true, - directoryName: handle.name || state.meta.directoryName || 'backup', - lastPermissionOk: ok - }); - } - } catch (error) { - console.warn('[ExternalBackup] load handle failed:', error); - } - state.ready = true; - return true; - })(); - return state.readyPromise; + function deriveFrequency(recordPayload = {}, examEntry = null, metadata = {}) { + return recordPayload.frequency + || metadata.frequency + || (examEntry && examEntry.frequency) + || 'unknown'; } - async function init() { - await ensureReady(); - ensurePanelDom(); - refreshExternalBackupPanel(); - await requestPersistentStorage(); + function fromCompletion(payload, sessionContext = {}, examEntry = null, options = {}) { + const normalizedMessage = normalizeMessage(payload); + const rawPayload = normalizedMessage && isPracticeCompleteType(normalizedMessage.type) + ? normalizedMessage.data + : (isPlainObject(payload) ? payload : {}); - // Daily reminder + empty-store recovery (deferred so PracticeRecordAPI can boot) - global.setTimeout(function () { - maybeShowDailyReminder({ render: true }).catch(function (error) { - console.warn('[ExternalBackup] daily reminder failed:', error); - }); - maybePromptEmptyStoreRecovery().catch(function (error) { - console.warn('[ExternalBackup] recovery prompt failed:', error); - }); - }, 1800); - - // Re-check when user returns to the tab (still at most once/day) - if (global.document) { - global.document.addEventListener('visibilitychange', function () { - if (global.document.visibilityState === 'visible') { - maybeShowDailyReminder({ render: true }).catch(function () { /* ignore */ }); - refreshExternalBackupPanel(); - } else if (global.document.visibilityState === 'hidden') { - // Best-effort silent write when leaving the tab (no permission prompt) - flushSilentlyIfPermitted().catch(function () { /* ignore */ }); - } - }); + if (!rawPayload || typeof rawPayload !== 'object') { + return null; } - } - - // Listen for data changes early - try { - global.addEventListener('storage-sync', onStorageSync); - global.addEventListener('practiceRecordsUpdated', markDirty); - } catch (_) { /* ignore */ } - - global.ExternalBackupService = { - __stable: true, - LATEST_FILENAME: LATEST_FILENAME, - supportsFileSystemAccess: supportsFileSystemAccess, - ensureReady: ensureReady, - init: init, - openModal: openModal, - closeModal: closeModal, - bindDirectory: bindDirectory, - unbindDirectory: unbindDirectory, - writeNow: function (options) { - return writeToBoundDirectory(Object.assign({ interactive: true, force: true }, options || {})); - }, - restoreFromLatest: restoreFromLatest, - pickAndRestoreFile: pickAndRestoreFile, - getStatus: getStatus, - formatStatusText: formatStatusText, - maybeShowDailyReminder: maybeShowDailyReminder, - maybePromptEmptyStoreRecovery: maybePromptEmptyStoreRecovery, - markDirty: markDirty, - flushSilentlyIfPermitted: flushSilentlyIfPermitted, - refreshPanel: refreshExternalBackupPanel, - requestPersistentStorage: requestPersistentStorage - }; - - global.refreshExternalBackupPanel = refreshExternalBackupPanel; - - function boot() { - init().catch(function (error) { - console.warn('[ExternalBackup] init failed:', error); - }); - } - - if (global.document && global.document.readyState === 'loading') { - global.document.addEventListener('DOMContentLoaded', boot); - } else { - boot(); - } -})(typeof window !== 'undefined' ? window : globalThis); - - -/* ===== js/core/practiceStore.js ===== */ -(function initPracticeStore(global) { - 'use strict'; - function getPracticeRecordAPI() { - if (!global.PracticeRecordAPI) { - throw new Error('PracticeStore: PracticeRecordAPI not ready'); + const scoreInfo = Object.assign({}, rawPayload.scoreInfo || {}); + const metadata = Object.assign({}, sessionContext.metadata || {}, rawPayload.metadata || {}); + const resolvedExamId = rawPayload.examId + || sessionContext.examId + || metadata.examId + || (examEntry && examEntry.id) + || null; + const answerComparison = normalizeAnswerComparison( + rawPayload.answerComparison || (rawPayload.realData && rawPayload.realData.answerComparison) || null + ); + const answerMap = mergeAnswerSources( + rawPayload.answerMap, + rawPayload.answers, + rawPayload.realData && rawPayload.realData.answers, + sessionContext.answers, + convertComparisonToMap(answerComparison, 'userAnswer') + ); + const correctAnswerMap = mergeAnswerSources( + rawPayload.correctAnswerMap, + rawPayload.realData && rawPayload.realData.correctAnswerMap, + sessionContext.correctAnswerMap, + rawPayload.correctAnswers, + rawPayload.realData && rawPayload.realData.correctAnswers, + deriveCorrectMapFromDetails(scoreInfo.details), + deriveCorrectMapFromDetails(rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details), + convertComparisonToMap(answerComparison, 'correctAnswer') + ); + const answerDetails = rawPayload.answerDetails + || scoreInfo.details + || (rawPayload.realData && rawPayload.realData.scoreInfo && rawPayload.realData.scoreInfo.details) + || buildAnswerDetails(answerMap, correctAnswerMap); + const answerList = buildAnswerArray(answerMap, correctAnswerMap); + const totalQuestions = ensureNumber( + rawPayload.totalQuestions ?? scoreInfo.total ?? scoreInfo.totalQuestions, + Object.keys(correctAnswerMap).length || Object.keys(answerMap).length + ); + const correctAnswers = ensureNumber( + rawPayload.correctAnswers ?? rawPayload.correctAnswersCount ?? scoreInfo.correct ?? scoreInfo.score ?? rawPayload.score, + deriveCorrectAnswerCount({ answerDetails, scoreInfo }, answerList) + ); + let accuracy = typeof rawPayload.accuracy === 'number' + ? rawPayload.accuracy + : (typeof scoreInfo.accuracy === 'number' + ? scoreInfo.accuracy + : (totalQuestions > 0 ? correctAnswers / totalQuestions : 0)); + if (accuracy > 1 && accuracy <= 100) { + accuracy = accuracy / 100; } - return global.PracticeRecordAPI; - } + const percentage = typeof scoreInfo.percentage === 'number' + ? scoreInfo.percentage + : Math.round(accuracy * 100); + const completedAt = resolveRecordDate({ + metadata, + date: rawPayload.date, + endTime: rawPayload.endTime, + completedAt: rawPayload.completedAt, + startTime: rawPayload.startTime, + timestamp: rawPayload.timestamp + }); + const duration = ensureNumber( + rawPayload.duration, + (rawPayload.endTime && rawPayload.startTime) + ? Math.round((new Date(rawPayload.endTime) - new Date(rawPayload.startTime)) / 1000) + : ensureNumber(sessionContext.duration, 0) + ); + const startTime = rawPayload.startTime + ? new Date(rawPayload.startTime).toISOString() + : (sessionContext.startTime + ? new Date(sessionContext.startTime).toISOString() + : new Date(new Date(completedAt).getTime() - duration * 1000).toISOString()); + const endTime = rawPayload.endTime + ? new Date(rawPayload.endTime).toISOString() + : completedAt; + const category = deriveCategory(rawPayload, examEntry, metadata); + const frequency = deriveFrequency(rawPayload, examEntry, metadata); + const title = rawPayload.title + || metadata.examTitle + || metadata.title + || (examEntry && examEntry.title) + || resolvedExamId + || '未命名练习'; + const annotations = resolveAnnotationState(rawPayload, [sessionContext]); + const resolvedQuestionTypeMap = isPlainObject(rawPayload.questionTypeMap) + ? clonePlainObject(rawPayload.questionTypeMap) + : (isPlainObject(rawPayload.realData && rawPayload.realData.questionTypeMap) + ? clonePlainObject(rawPayload.realData.questionTypeMap) + : {}); + const suiteEntries = rawPayload.suiteEntries || metadata.suiteEntries || []; + const suiteSessionId = rawPayload.suiteSessionId || metadata.suiteSessionId || sessionContext.suiteSessionId || null; - async function list() { - var api = getPracticeRecordAPI(); - if (typeof api.list !== 'function') { - throw new Error('PracticeStore.list: PracticeRecordAPI.list not ready'); - } - var records = await api.list(); - return Array.isArray(records) ? records : []; + return standardizeRecord({ + id: rawPayload.id, + examId: resolvedExamId, + sessionId: rawPayload.sessionId || sessionContext.sessionId || null, + title, + type: rawPayload.type || metadata.type || metadata.examType || (examEntry && examEntry.type) || sessionContext.type || null, + startTime, + endTime, + duration, + date: completedAt, + status: rawPayload.status || 'completed', + score: ensureNumber(rawPayload.score ?? scoreInfo.score, correctAnswers), + totalQuestions, + correctAnswers, + accuracy, + answers: answerList, + answerDetails, + correctAnswerMap, + answerComparison, + questionTypePerformance: rawPayload.questionTypePerformance || {}, + metadata: Object.assign({}, metadata, { + examId: resolvedExamId, + examTitle: title, + category, + frequency, + markedQuestions: clonePlainObject(annotations.markedQuestions) + }), + frequency, + suiteMode: Boolean(rawPayload.suiteMode || (String(rawPayload.practiceMode || metadata.practiceMode || '').toLowerCase() === 'suite')), + suiteSessionId, + suiteEntries, + ...annotations, + questionTypeMap: resolvedQuestionTypeMap, + scoreInfo: Object.assign({}, scoreInfo, { + correct: correctAnswers, + total: totalQuestions, + accuracy, + percentage, + details: scoreInfo.details || answerDetails, + source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page' + }), + realData: Object.assign({}, rawPayload.realData || {}, { + answers: answerMap, + correctAnswers: correctAnswerMap, + answerComparison, + correctAnswerMap, + ...clonePlainObject(annotations), + questionTypeMap: resolvedQuestionTypeMap, + scoreInfo: Object.assign({}, (rawPayload.realData && rawPayload.realData.scoreInfo) || scoreInfo, { + correct: correctAnswers, + total: totalQuestions, + accuracy, + percentage, + details: answerDetails, + source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page' + }), + interactions: rawPayload.interactions || [], + isRealData: true, + source: scoreInfo.source || rawPayload.pageType || rawPayload.source || 'practice_page', + sessionId: rawPayload.sessionId || sessionContext.sessionId || null + }) + }, options); } - async function replace(records, options) { - var finalRecords = Array.isArray(records) ? records : []; - var api = getPracticeRecordAPI(); - if (typeof api.replace !== 'function') { - throw new Error('PracticeStore.replace: PracticeRecordAPI.replace not ready'); - } - await api.replace(finalRecords, Object.assign({ updateStats: true }, options || {})); - return true; - } + const contracts = Object.freeze({ + ensureNumber, + normalizePracticeType, + inferPracticeType, + resolveRecordDate, + inferExamId, + normalizeAnswerValue, + isNoiseKey, + normalizeAnswerMap, + normalizeReplayQuestionKey, + normalizeReplayMap, + normalizeAnswerComparison, + mergeAnswerSources, + buildReplayCorrectAnswerMap, + buildReplayResultSnapshot, + resolveCorrectAnswerMap, + resolveRecordCorrectAnswerMap, + compareAnswerValues, + buildAnswerArray, + buildAnswerDetails, + deriveCorrectMapFromDetails, + deriveCorrectAnswerCount, + deriveTotalQuestionCount, + convertComparisonToMap, + convertComparisonToDetails, + buildMetadata, + standardizeRecord, + standardizeSuiteEntries, + resolveAnnotationState, + clonePlainObject + }); - async function save(record, options) { - var api = getPracticeRecordAPI(); - if (typeof api.saveRecord !== 'function') { - throw new Error('PracticeStore.save: PracticeRecordAPI.saveRecord not ready'); - } - return api.saveRecord(record, Object.assign({ updateStats: true }, options || {})); - } + const protocol = Object.freeze({ + MESSAGE_TYPE_ALIASES, + PRACTICE_COMPLETE_TYPES, + normalizeMessageType, + normalizeMessage, + isPracticeCompleteType, + buildEnvelope + }); - async function clear(options) { - var api = getPracticeRecordAPI(); - if (typeof api.clear === 'function') { - await api.clear(Object.assign({ updateStats: true }, options || {})); - return true; - } - return replace([], options || {}); - } + const ingestor = Object.freeze({ + fromCompletion + }); - global.PracticeStore = Object.assign({}, global.PracticeStore || {}, { - list: list, - replace: replace, - save: save, - clear: clear + const practiceCore = Object.freeze({ + __stable: true, + version: '0.6.2-fix', + contracts, + protocol, + ingestor }); + global.PracticeCore = practiceCore; })(typeof window !== 'undefined' ? window : globalThis); @@ -9245,8 +7583,6 @@ storageManager.ready const PATH_PROTOCOL_RE = /^(?:[a-z]+:)?\/\//i; const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/; - const PATH_MAP_STORAGE_PREFIX = 'exam_path_map__'; - const BASE_PREFIX_STORAGE_KEY = 'resource.basePrefix'; const PATH_FALLBACK_ORDER = ['map', 'fallback', 'raw', 'relative-up', 'relative-design']; const RAW_DEFAULT_PATH_MAP = { reading: { @@ -9413,10 +7749,6 @@ storageManager.ready return result; } - function getPathMapStorageKey(key) { - return PATH_MAP_STORAGE_PREFIX + key; - } - function setActivePathMap(map) { const normalized = normalizePathMap(map); try { global.__activeLibraryPathMap = normalized; } catch (_) { } @@ -9435,14 +7767,10 @@ storageManager.ready } async function loadPathMapForConfiguration(key) { - if (!key || !global.storage || typeof global.storage.get !== 'function') { - return clonePathMap(DEFAULT_PATH_MAP); - } + if (!key || !global.AppData || !global.AppData.library) return clonePathMap(DEFAULT_PATH_MAP); try { - const stored = await global.storage.get(getPathMapStorageKey(key)); - if (stored && typeof stored === 'object') { - return normalizePathMap(stored, DEFAULT_PATH_MAP); - } + const index = await global.AppData.library.getIndex(key); + return index.length ? derivePathMapFromIndex(index, DEFAULT_PATH_MAP) : clonePathMap(DEFAULT_PATH_MAP); } catch (error) { console.warn('[ResourceCore] 读取路径映射失败:', error); } @@ -9459,14 +7787,6 @@ storageManager.ready ? normalizePathMap(overrideMap, fallback) : derivePathMapFromIndex(exams, fallback); - if (global.storage && typeof global.storage.set === 'function') { - try { - await global.storage.set(getPathMapStorageKey(key), derived); - } catch (error) { - console.warn('[ResourceCore] 写入路径映射失败:', error); - } - } - if (options.setActive) { setActivePathMap(derived); } @@ -9474,25 +7794,16 @@ storageManager.ready } async function deletePathMapForConfiguration(key) { - if (!key || !global.storage || typeof global.storage.remove !== 'function') { - return false; - } - try { - await global.storage.remove(getPathMapStorageKey(key)); - return true; - } catch (error) { - console.warn('[ResourceCore] 删除路径映射失败:', error); - return false; - } + return Boolean(key); } async function refreshPathMap() { - if (!global.storage || typeof global.storage.get !== 'function') { + if (!global.AppData || !global.AppData.library) { return setActivePathMap(getPathMap()); } try { - const key = await global.storage.get('active_exam_index_key', 'exam_index'); - const next = await loadPathMapForConfiguration(key || 'exam_index'); + const key = await global.AppData.library.getActive(); + const next = await loadPathMapForConfiguration(key); return setActivePathMap(next); } catch (error) { console.warn('[ResourceCore] 刷新路径映射失败:', error); @@ -9618,22 +7929,13 @@ storageManager.ready return null; } - function loadStoredBasePrefix() { - try { - return localStorage.getItem(BASE_PREFIX_STORAGE_KEY) || ''; - } catch (_) { - return ''; - } - } + let storedBasePrefix = ''; function storeBasePrefix(value) { - try { - if (value) { - localStorage.setItem(BASE_PREFIX_STORAGE_KEY, value); - } else { - localStorage.removeItem(BASE_PREFIX_STORAGE_KEY); - } - } catch (_) { } + storedBasePrefix = value || ''; + if (global.AppData && global.AppData.preferences) { + global.AppData.preferences.setResourceBasePrefix(storedBasePrefix).catch(() => {}); + } } function getBasePrefix() { @@ -9642,7 +7944,7 @@ storageManager.ready return direct; } - const stored = normalizeBasePrefix(loadStoredBasePrefix()); + const stored = normalizeBasePrefix(storedBasePrefix); if (stored && stored !== './') { global.RESOURCE_BASE_PREFIX = stored; return stored; @@ -9664,6 +7966,13 @@ storageManager.ready return normalized; } + if (global.AppData && global.AppData.preferences) { + global.AppData.preferences.getResourceBasePrefix().then((value) => { + storedBasePrefix = value || ''; + if (!global.RESOURCE_BASE_PREFIX && storedBasePrefix) global.RESOURCE_BASE_PREFIX = normalizeBasePrefix(storedBasePrefix); + }).catch(() => {}); + } + function resolveGeneratedReadingRuntimeUrl(exam, kind = 'html') { if (!exam || kind === 'pdf') { return ''; @@ -9898,14 +8207,12 @@ storageManager.ready version: '0.6.2-fix', RAW_DEFAULT_PATH_MAP, DEFAULT_PATH_MAP, - PATH_MAP_STORAGE_PREFIX, PATH_FALLBACK_ORDER, clonePathMap, normalizePathRoot, mergeRootWithFallback, buildOverridePathMap, derivePathMapFromIndex, - getPathMapStorageKey, getPathMap, setActivePathMap, loadPathMapForConfiguration, @@ -13375,456 +11682,92 @@ storageManager.ready "script": "./p2-low-242.js", "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-240": { - "examId": "p3-low-240", - "dataKey": "p3-low-240", - "script": "./p3-low-240.js", - "title": "How a prehistoric predator took to the skies 翼龙飞行", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - How a prehistoric predator took to the skies.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-241": { - "examId": "p3-medium-241", - "dataKey": "p3-medium-241", - "script": "./p3-medium-241.js", - "title": "Who looks after the children in today's Britain? 育儿分工", - "category": "P3", - "frequency": "medium", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - Who looks after the children in today's Britain.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", - "sourceKind": "generated-reading" - } - }; - - function clonePathRoot() { - return Object.assign({}, PATH_ROOT); - } - - function cloneIndexEntry(entry) { - return Object.assign({}, entry); - } - - function buildReadingExamIndex() { - const index = Object.keys(manifest).map(function mapEntry(id) { - const entry = manifest[id] || {}; - return { - id: entry.examId || id, - title: entry.title || '', - category: entry.category || '', - frequency: entry.frequency || '', - difficultyScore: entry.difficultyScore, - path: entry.path || '', - filename: entry.filename || '', - hasHtml: entry.hasHtml === true, - hasPdf: entry.hasPdf === true, - pdfFilename: entry.pdfFilename || '', - sourceKind: entry.sourceKind || (entry.script ? 'generated-reading' : 'pdf-only'), - type: 'reading' - }; - }); - index.pathRoot = clonePathRoot(); - return index; - } - - function getReadingExamIndex() { - const index = global.__READING_EXAM_INDEX__; - const cloned = Array.isArray(index) ? index.map(cloneIndexEntry) : buildReadingExamIndex(); - cloned.pathRoot = clonePathRoot(); - return cloned; - } - - global.__READING_EXAM_MANIFEST__ = manifest; - global.__READING_EXAM_INDEX__ = buildReadingExamIndex(); - global.__READING_EXAM_INDEX__.pathRoot = clonePathRoot(); - global.__READING_EXAM_PATH_ROOT__ = clonePathRoot(); - global.getReadingExamIndex = getReadingExamIndex; - global.getReadingExamIndex.pathRoot = clonePathRoot(); - global.completeExamIndex = getReadingExamIndex(); -})(typeof window !== "undefined" ? window : globalThis); - - -/* ===== js/utils/stateSerializer.js ===== */ -/** - * 状态序列化适配器 - * 解决Set/Map对象无法直接JSON序列化的问题 - */ - -class StateSerializer { - /** - * 序列化状态值,处理特殊对象类型 - */ - static serialize(value) { - if (value === null || value === undefined) { - return value; - } - - // 处理Set对象 - if (value instanceof Set) { - return { - __type: 'Set', - __value: Array.from(value) - }; - } - - // 处理Map对象 - if (value instanceof Map) { - return { - __type: 'Map', - __value: Array.from(value.entries()) - }; - } - - // 处理Date对象 - if (value instanceof Date) { - return { - __type: 'Date', - __value: value.toISOString() - }; - } - - // 处理普通对象,递归处理嵌套 - if (typeof value === 'object') { - if (Array.isArray(value)) { - return value.map(item => StateSerializer.serialize(item)); - } else { - const serialized = {}; - for (const [key, val] of Object.entries(value)) { - serialized[key] = StateSerializer.serialize(val); - } - return serialized; - } - } - - // 基本类型直接返回 - return value; - } - - /** - * 反序列化状态值,恢复特殊对象类型 - */ - static deserialize(value) { - if (value === null || value === undefined) { - return value; - } - - // 检查是否是特殊类型对象 - if (typeof value === 'object' && value !== null && '__type' in value) { - switch (value.__type) { - case 'Set': - return new Set(value.__value); - case 'Map': - return new Map(value.__value); - case 'Date': - return new Date(value.__value); - default: - console.warn(`[StateSerializer] 未知类型: ${value.__type}`); - return value.__value; - } - } - - // 处理数组 - if (Array.isArray(value)) { - return value.map(item => StateSerializer.deserialize(item)); - } - - // 处理普通对象,递归处理嵌套 - if (typeof value === 'object') { - const deserialized = {}; - for (const [key, val] of Object.entries(value)) { - deserialized[key] = StateSerializer.deserialize(val); - } - return deserialized; - } - - // 基本类型直接返回 - return value; - } - - /** - * 验证序列化/反序列化的一致性 - */ - static validate(originalValue) { - try { - const serialized = StateSerializer.serialize(originalValue); - const deserialized = StateSerializer.deserialize(serialized); - - // 对于Set/Map,深度比较内容 - if (originalValue instanceof Set) { - const originalArray = Array.from(originalValue); - const deserializedArray = Array.from(deserialized); - return JSON.stringify(originalArray.sort()) === JSON.stringify(deserializedArray.sort()); - } - - if (originalValue instanceof Map) { - const originalArray = Array.from(originalValue.entries()).sort(); - const deserializedArray = Array.from(deserialized.entries()).sort(); - return JSON.stringify(originalArray) === JSON.stringify(deserializedArray); - } - - // 其他类型直接比较 - return JSON.stringify(originalValue) === JSON.stringify(deserialized); - } catch (error) { - console.error('[StateSerializer] 验证失败:', error); - return false; - } - } - - /** - * 创建存储适配器,包装storage对象 - */ - static createStorageAdapter(baseStorage) { - return { - async get(key, defaultValue = null) { - try { - const value = await baseStorage.get(key, defaultValue); - return StateSerializer.deserialize(value); - } catch (error) { - console.error(`[StateSerializer] 获取数据失败 ${key}:`, error); - return defaultValue; - } - }, - - async set(key, value) { - try { - const serializedValue = StateSerializer.serialize(value); - return await baseStorage.set(key, serializedValue); - } catch (error) { - console.error(`[StateSerializer] 设置数据失败 ${key}:`, error); - throw error; - } - }, - - async remove(key) { - try { - return await baseStorage.remove(key); - } catch (error) { - console.error(`[StateSerializer] 删除数据失败 ${key}:`, error); - throw error; - } - }, - - async clear() { - try { - return await baseStorage.clear(); - } catch (error) { - console.error('[StateSerializer] 清空存储失败:', error); - throw error; - } - } - }; + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-240": { + "examId": "p3-low-240", + "dataKey": "p3-low-240", + "script": "./p3-low-240.js", + "title": "How a prehistoric predator took to the skies 翼龙飞行", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - How a prehistoric predator took to the skies.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-241": { + "examId": "p3-medium-241", + "dataKey": "p3-medium-241", + "script": "./p3-medium-241.js", + "title": "Who looks after the children in today's Britain? 育儿分工", + "category": "P3", + "frequency": "medium", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - Who looks after the children in today's Britain.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", + "sourceKind": "generated-reading" } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = StateSerializer; -} - - -/* ===== js/utils/simpleStorageWrapper.js ===== */ -(function(window) { - class SimpleStorageWrapper { - constructor(repositories) { - this.repos = repositories; - } - - get settingsRepo() { return this.repos.settings; } - get backupRepo() { return this.repos.backups; } - get metaRepo() { return this.repos.meta; } - - isPracticeDataKey(key) { - return key === 'practice_records' || key === 'user_stats'; - } - - getPracticeRecordAPI() { - const api = window.PracticeRecordAPI; - if (!api) { - throw new Error('PracticeRecordAPI unavailable'); - } - return api; - } - - rejectPracticeDataWrite(methodName, targetName) { - throw new Error(`SimpleStorageWrapper.${methodName} is disabled; use ${targetName}`); - } - - async getPracticeRecords() { - const api = this.getPracticeRecordAPI(); - if (typeof api.list !== 'function') { - throw new Error('PracticeRecordAPI.list unavailable'); - } - return await api.list(); - } - - async savePracticeRecords() { - this.rejectPracticeDataWrite('savePracticeRecords', 'PracticeRecordAPI.replace'); - } - - async addPracticeRecord() { - this.rejectPracticeDataWrite('addPracticeRecord', 'PracticeRecordAPI.saveRecord'); - } - - async getById(id) { - const api = this.getPracticeRecordAPI(); - if (typeof api.getById !== 'function') { - throw new Error('PracticeRecordAPI.getById unavailable'); - } - return await api.getById(id); - } - - async update() { - this.rejectPracticeDataWrite('update', 'PracticeRecordAPI.saveRecord'); - } - - async delete() { - this.rejectPracticeDataWrite('delete', 'PracticeRecordAPI.deleteById'); - } - - async deletePracticeRecord() { - this.rejectPracticeDataWrite('deletePracticeRecord', 'PracticeRecordAPI.deleteById'); - } - - async deletePracticeRecords() { - this.rejectPracticeDataWrite('deletePracticeRecords', 'PracticeRecordAPI.deleteMany'); - } - - async getPracticeRecordsCount() { - const records = await this.getPracticeRecords(); - return Array.isArray(records) ? records.length : 0; - } - - validatePracticeRecord(record) { - const errors = []; - if (!record || typeof record !== 'object') { - errors.push('记录必须是对象'); - } else { - if (!record.id || typeof record.id !== 'string') { - errors.push('记录缺少有效的 id'); - } - if (!record.type || typeof record.type !== 'string') { - errors.push('记录缺少有效的 type'); - } - if (record.score === undefined || record.score === null || typeof record.score !== 'number') { - errors.push('记录缺少有效的 score'); - } - if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') { - errors.push('totalQuestions 必须是数字'); - } - if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') { - errors.push('correctAnswers 必须是数字'); - } - if (record.duration !== undefined && typeof record.duration !== 'number') { - errors.push('duration 必须是数字'); - } - if (!record.date) { - errors.push('记录缺少有效的 date'); - } else if (Number.isNaN(new Date(record.date).getTime())) { - errors.push('date 格式无效'); - } - } - return { - isValid: errors.length === 0, - errors - }; - } - - async getUserSettings() { return await this.settingsRepo.getAll(); } - async saveUserSettings(settings) { await this.settingsRepo.saveAll(settings); return true; } - async getUserSetting(key, defaultValue = null) { return await this.settingsRepo.get(key, defaultValue); } - async setUserSetting(key, value) { await this.settingsRepo.set(key, value); return true; } - - async getBackups() { return await this.backupRepo.list(); } - async saveBackups(backups) { await this.backupRepo.saveAll(backups); return true; } - async addBackup(backup) { await this.backupRepo.add(backup); return true; } - async deleteBackup(id) { return await this.backupRepo.delete(id); } - async clearBackups() { await this.backupRepo.clear(); return true; } - - async get(key, defaultValue = null) { - if (this.isPracticeDataKey(key)) { - const api = this.getPracticeRecordAPI(); - if (key === 'practice_records') { - if (typeof api.list !== 'function') { - throw new Error('PracticeRecordAPI.list unavailable'); - } - return await api.list(); - } - if (typeof api.readStats !== 'function') { - throw new Error('PracticeRecordAPI.readStats unavailable'); - } - return await api.readStats({ fallback: defaultValue }); - } - return await this.metaRepo.get(key, defaultValue); - } + }; - async set(key, value) { - if (this.isPracticeDataKey(key)) { - if (key === 'practice_records') { - this.rejectPracticeDataWrite('set(practice_records)', 'PracticeRecordAPI.replace'); - } - this.rejectPracticeDataWrite('set(user_stats)', 'PracticeRecordAPI.writeStats'); - } - await this.metaRepo.set(key, value); - return true; - } + function clonePathRoot() { + return Object.assign({}, PATH_ROOT); + } - async remove(key) { - if (this.isPracticeDataKey(key)) { - if (key === 'practice_records') { - this.rejectPracticeDataWrite('remove(practice_records)', 'PracticeRecordAPI.clear'); - } - this.rejectPracticeDataWrite('remove(user_stats)', 'PracticeRecordAPI.resetStats'); - } - await this.metaRepo.remove(key); - return true; - } - } + function cloneIndexEntry(entry) { + return Object.assign({}, entry); + } - function connectWrapper(repositories) { - if (!repositories) { - return; - } - if (window.simpleStorageWrapper && window.simpleStorageWrapper.repos === repositories) { - return; - } - window.simpleStorageWrapper = new SimpleStorageWrapper(repositories); - console.log('[SimpleStorageWrapper] 已连接新的数据仓库接口'); - } + function buildReadingExamIndex() { + const index = Object.keys(manifest).map(function mapEntry(id) { + const entry = manifest[id] || {}; + return { + id: entry.examId || id, + title: entry.title || '', + category: entry.category || '', + frequency: entry.frequency || '', + difficultyScore: entry.difficultyScore, + path: entry.path || '', + filename: entry.filename || '', + hasHtml: entry.hasHtml === true, + hasPdf: entry.hasPdf === true, + pdfFilename: entry.pdfFilename || '', + sourceKind: entry.sourceKind || (entry.script ? 'generated-reading' : 'pdf-only'), + type: 'reading' + }; + }); + index.pathRoot = clonePathRoot(); + return index; + } - const registry = window.StorageProviderRegistry; - if (registry && typeof registry.onProvidersReady === 'function') { - registry.onProvidersReady(({ repositories }) => connectWrapper(repositories)); - const current = registry.getCurrentProviders && registry.getCurrentProviders(); - if (current && current.repositories) { - connectWrapper(current.repositories); - } - } else if (window.dataRepositories) { - connectWrapper(window.dataRepositories); - } else { - console.warn('[SimpleStorageWrapper] 数据仓库尚未可用,等待外部注入'); - } + function getReadingExamIndex() { + const index = global.__READING_EXAM_INDEX__; + const cloned = Array.isArray(index) ? index.map(cloneIndexEntry) : buildReadingExamIndex(); + cloned.pathRoot = clonePathRoot(); + return cloned; + } - window.SimpleStorageWrapper = SimpleStorageWrapper; -})(window); + global.__READING_EXAM_MANIFEST__ = manifest; + global.__READING_EXAM_INDEX__ = buildReadingExamIndex(); + global.__READING_EXAM_INDEX__.pathRoot = clonePathRoot(); + global.__READING_EXAM_PATH_ROOT__ = clonePathRoot(); + global.getReadingExamIndex = getReadingExamIndex; + global.getReadingExamIndex.pathRoot = clonePathRoot(); + global.completeExamIndex = getReadingExamIndex(); +})(typeof window !== "undefined" ? window : globalThis); /* ===== js/app/state-service.js ===== */ @@ -13835,32 +11778,6 @@ if (typeof module !== 'undefined' && module.exports) { return Array.isArray(value) ? value.slice() : []; } - function cloneValue(value) { - if (value === null || value === undefined) { - return value; - } - if (typeof global.structuredClone === 'function') { - try { - return global.structuredClone(value); - } catch (_) { } - } - try { - return JSON.parse(JSON.stringify(value)); - } catch (_) { - if (Array.isArray(value)) { - return value.map((item) => cloneValue(item)); - } - if (value && typeof value === 'object') { - return Object.assign({}, value); - } - return value; - } - } - - function clonePracticeRecords(records) { - return Array.isArray(records) ? records.map((record) => cloneValue(record)) : []; - } - function cloneSet(value) { if (value instanceof Set) { return new Set(value); @@ -14001,8 +11918,6 @@ if (typeof module !== 'undefined' && module.exports) { this.globalBindingsInstalled = false; this.state = { - examIndex: cloneArray(global.examIndex), - practiceRecords: [], filteredExams: Array.isArray(global.filteredExams) ? global.filteredExams : [], browseFilter: normalizeFilter(global.__browseFilter), bulkDeleteMode: !!global.bulkDeleteMode, @@ -14013,8 +11928,6 @@ if (typeof module !== 'undefined' && module.exports) { }; this.listeners = { - examIndex: new Set(), - practiceRecords: new Set(), filteredExams: new Set(), browseFilter: new Set(), bulkDeleteMode: new Set(), @@ -14070,13 +11983,11 @@ if (typeof module !== 'undefined' && module.exports) { try { if (app.state.exam) { - app.state.exam.index = this.state.examIndex; app.state.exam.currentCategory = this.state.browseFilter.category; app.state.exam.currentExamType = this.state.browseFilter.type; app.state.exam.filteredExams = this.state.filteredExams; } if (app.state.practice) { - app.state.practice.records = clonePracticeRecords(this.state.practiceRecords); app.state.practice.selectedRecords = this.state.selectedRecords; app.state.practice.bulkDeleteMode = this.state.bulkDeleteMode; } @@ -14096,12 +12007,6 @@ if (typeof module !== 'undefined' && module.exports) { syncFromAppPath(path, value) { switch (path) { - case 'exam.index': - this.setExamIndex(value, { syncApp: false }); - break; - case 'practice.records': - this.setPracticeRecords(value, { syncApp: false }); - break; case 'exam.filteredExams': this.setFilteredExams(value, { syncApp: false }); break; @@ -14141,41 +12046,6 @@ if (typeof module !== 'undefined' && module.exports) { } } - getExamIndex() { - return this.state.examIndex; - } - - setExamIndex(list, options = {}) { - const normalized = assignExamSequenceNumbers(cloneArray(list)); - this.state.examIndex = normalized; - if (options.syncApp !== false) { - this.applyToApp(); - } - emit(this.listeners, 'examIndex', this.state.examIndex); - return this.state.examIndex; - } - - getPracticeRecords() { - return clonePracticeRecords(this.state.practiceRecords); - } - - setPracticeRecords(records, options = {}) { - const normalized = clonePracticeRecords(records); - this.state.practiceRecords = normalized; - if (options.syncApp !== false) { - this.applyToApp(); - } - emit(this.listeners, 'practiceRecords', clonePracticeRecords(this.state.practiceRecords)); - if (typeof global.updateBrowseAnchorsFromRecords === 'function') { - try { - global.updateBrowseAnchorsFromRecords(clonePracticeRecords(this.state.practiceRecords)); - } catch (error) { - console.warn('[AppStateService] updateBrowseAnchorsFromRecords failed:', error); - } - } - return clonePracticeRecords(this.state.practiceRecords); - } - getFilteredExams() { return this.state.filteredExams; } @@ -14430,18 +12300,6 @@ if (typeof module !== 'undefined' && module.exports) { const service = this; - globalRef.getExamIndexState = function getExamIndexState() { - return service.getExamIndex(); - }; - globalRef.setExamIndexState = function setExamIndexState(list) { - return service.setExamIndex(list); - }; - globalRef.getPracticeRecordsState = function getPracticeRecordsState() { - return service.getPracticeRecords(); - }; - globalRef.setPracticeRecordsState = function setPracticeRecordsState(records) { - return service.setPracticeRecords(records); - }; globalRef.getFilteredExamsState = function getFilteredExamsState() { return service.getFilteredExams(); }; @@ -14501,14 +12359,6 @@ if (typeof module !== 'undefined' && module.exports) { }; globalRef.assignExamSequenceNumbers = assignExamSequenceNumbers; - defineGlobalProperty(globalRef, 'examIndex', { - get: () => service.getExamIndex(), - set: (value) => service.setExamIndex(value) - }); - defineGlobalProperty(globalRef, 'practiceRecords', { - get: () => service.getPracticeRecords(), - set: (value) => service.setPracticeRecords(value) - }); defineGlobalProperty(globalRef, 'filteredExams', { get: () => service.getFilteredExams(), set: (value) => service.setFilteredExams(value) @@ -15360,23 +13210,14 @@ if (typeof module !== 'undefined' && module.exports) { && global.listeningExamIndex.length > 0; } - function getActiveExamIndexSnapshot() { - try { - if (typeof global.getExamIndexState === 'function') { - return global.getExamIndexState(); - } - } catch (_) { } - return Array.isArray(global.examIndex) ? global.examIndex : []; - } - function hasActiveListeningLibrary(index) { - return hasListeningEntries(Array.isArray(index) ? index : getActiveExamIndexSnapshot()); + return hasListeningEntries(index); } function refreshListeningAvailabilityUI(index) { if (typeof global.refreshListeningAvailabilityUI === 'function') { try { - global.refreshListeningAvailabilityUI(Array.isArray(index) ? index : getActiveExamIndexSnapshot()); + global.refreshListeningAvailabilityUI(Array.isArray(index) ? index : []); return; } catch (error) { console.warn('[LibraryManager] 刷新听力入口状态失败:', error); @@ -15465,36 +13306,26 @@ if (typeof module !== 'undefined' && module.exports) { } async getActiveLibraryConfigurationKey() { - return global.storage.get('active_exam_index_key', 'exam_index'); + return global.AppData.library.getActive(); } async setActiveLibraryConfiguration(key) { - try { - await global.storage.set('active_exam_index_key', key); - } catch (error) { - console.error('[LibraryManager] 设置活动题库配置失败:', error); - } + return global.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null); } async getLibraryConfigurations() { - return global.storage.get('exam_index_configurations', []); + const configurations = await global.AppData.library.listConfigurations(); + return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest' }] + .concat(Array.isArray(configurations) ? configurations : []); } async saveLibraryConfiguration(name, key, examCount, metadata = {}) { try { - let configs = await global.storage.get('exam_index_configurations', []); - if (!Array.isArray(configs)) { - configs = []; - } + if (!key) return; const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {}; - const entry = Object.assign({}, safeMetadata, { name, key, examCount, timestamp: Date.now() }); - const existingIndex = configs.findIndex((item) => item && item.key === key); - if (existingIndex >= 0) { - configs[existingIndex] = Object.assign({}, configs[existingIndex], entry); - } else { - configs.push(entry); - } - await global.storage.set('exam_index_configurations', configs); + await global.AppData.library.updateConfiguration(Object.assign({}, safeMetadata, { + id: key, key, name, examCount, timestamp: Date.now() + })); } catch (error) { console.error('[LibraryManager] 保存题库配置失败:', error); } @@ -15590,20 +13421,100 @@ if (typeof module !== 'undefined' && module.exports) { : []; } - finishLibraryLoading(startTime) { + finishLibraryLoading(startTime, index) { const loadTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() - startTime : 0; if (typeof global.reportBootStage === 'function') { global.reportBootStage('题库装载完成', 75); } - try { global.updateOverview && global.updateOverview(); } catch (_) { } - refreshListeningAvailabilityUI(); - try { global.refreshBrowseProgressFromRecords && global.refreshBrowseProgressFromRecords(); } catch (_) { } + try { global.updateOverview && global.updateOverview(index); } catch (_) { } + refreshListeningAvailabilityUI(index); + if (typeof global.startPracticeRecordsSyncInBackground === 'function') { + global.startPracticeRecordsSyncInBackground('library-loaded', { forceRender: true }); + } try { - global.dispatchEvent(new CustomEvent('examIndexLoaded')); + global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { index: cloneArray(index) } })); } catch (_) { } return loadTime; } + async resolveDefaultIndex() { + await global.AppData.ready; + if (global.ensureExamDataScripts) { + try { await global.ensureExamDataScripts(); } catch (_) { } + } + return this.normalizeIndexForCustomConfig( + this.getDefaultReadingIndex().concat(this.resolveDefaultTypeIndex('listening')) + ); + } + + async resolveIndexForConfiguration(configurationId) { + await global.AppData.ready; + const id = typeof configurationId === 'string' && configurationId.trim() + ? configurationId.trim() + : null; + if (id === null) return this.resolveDefaultIndex(); + return this.normalizeIndexForCustomConfig(await global.AppData.library.getIndex(id)); + } + + getRecordLibraryProvenance(record) { + const metadata = record && record.metadata && typeof record.metadata === 'object' + ? record.metadata + : {}; + if (Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId')) { + const value = metadata.libraryConfigurationId; + return { known: true, configurationId: typeof value === 'string' && value.trim() ? value.trim() : null }; + } + if (record && Object.prototype.hasOwnProperty.call(record, 'libraryConfigurationId')) { + const value = record.libraryConfigurationId; + return { known: true, configurationId: typeof value === 'string' && value.trim() ? value.trim() : null }; + } + return { known: false, configurationId: null }; + } + + async resolveIndexForRecord(record) { + const provenance = this.getRecordLibraryProvenance(record); + // 记录带明确题库来源时严格按来源解析——多题库场景下这能防止同一 examId + // 被解析到别的库里的错误题目。 + if (provenance.known) { + return this.resolveIndexForConfiguration(provenance.configurationId); + } + // 来源未知(几乎都是 v1 迁移来的旧记录:迁移时无法唯一确定来源就不会补 + // libraryConfigurationId)。条件降级:只有当用户没有任何自定义题库时, + // examId 只可能对应默认库里的唯一题目,回退到当前活动题库解析是安全的 + // (即 v1 一贯行为,修复旧记录回顾/详情/导出全部失败)。一旦存在自定义 + // 题库,同一 examId 可能在多个库指向不同题目,无来源就无法安全判定, + // 保守返回空索引,由调用方按“题目不可用”提示,绝不静默解析到错题。 + let customConfigCount = 0; + try { + const configurations = await global.AppData.library.listConfigurations(); + customConfigCount = Array.isArray(configurations) ? configurations.length : 0; + } catch (_) { + // 读配置失败时按保守处理,不回退。 + return []; + } + if (customConfigCount === 0) { + return this.resolveActiveIndex(); + } + return []; + } + + async resolveExamForRecord(record) { + if (!record || typeof record !== 'object') return null; + const metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {}; + const candidateIds = [record.examId, metadata.examId] + .filter((value) => value !== null && value !== undefined && String(value).trim()) + .map((value) => String(value)); + if (!candidateIds.length) return null; + const index = await this.resolveIndexForRecord(record); + return index.find((exam) => exam && candidateIds.includes(String(exam.id))) || null; + } + + async resolveActiveIndex() { + await global.AppData.ready; + const activeId = await global.AppData.library.getActive(); + return this.resolveIndexForConfiguration(activeId); + } + async loadActiveLibrary(forceReload = false) { const startTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); if (typeof global.reportBootStage === 'function') { @@ -15611,37 +13522,36 @@ if (typeof module !== 'undefined' && module.exports) { } const rawKey = await this.getActiveLibraryConfigurationKey(); - const activeConfigKey = typeof rawKey === 'string' && rawKey.trim() ? rawKey.trim() : 'exam_index'; - const isDefaultConfig = activeConfigKey === 'exam_index'; + const activeConfigKey = typeof rawKey === 'string' && rawKey.trim() ? rawKey.trim() : null; + const isDefaultConfig = activeConfigKey === null; let cachedData = null; try { if (!isDefaultConfig) { - cachedData = await global.storage.get(activeConfigKey); + cachedData = await global.AppData.library.getIndex(activeConfigKey); } else { - await global.storage.set('active_exam_index_key', 'exam_index'); + await global.AppData.library.activate(null); } } catch (error) { console.warn('[LibraryManager] 读取题库缓存失败:', error); } - if (!forceReload && !isDefaultConfig && Array.isArray(cachedData) && cachedData.length > 0) { - const updatedIndex = global.setExamIndexState ? global.setExamIndexState(cachedData) : cachedData; + if (!isDefaultConfig && Array.isArray(cachedData) && cachedData.length > 0) { + const updatedIndex = this.normalizeIndexForCustomConfig(cachedData); + if (typeof global.assignExamSequenceNumbers === 'function') global.assignExamSequenceNumbers(updatedIndex); await this.savePathMapForConfiguration(activeConfigKey, updatedIndex, { setActive: true }); - this.finishLibraryLoading(startTime); + this.finishLibraryLoading(startTime, updatedIndex); return updatedIndex; } if (!isDefaultConfig) { const normalized = Array.isArray(cachedData) ? cachedData : []; - if (global.setExamIndexState) { - global.setExamIndexState(normalized); - } if (!normalized.length && typeof global.showMessage === 'function') { - global.showMessage('当前题库配置没有数据,请重新导入或切换至默认题库。', 'warning'); + global.showMessage('当前题库配置没有数据,已自动切换至默认题库。', 'warning'); } - this.finishLibraryLoading(startTime); - return normalized; + // Continue through the built-in manifest path. Returning the empty + // custom index here used to dispatch examIndexLoaded([]) and left an + // otherwise valid generated Reading manifest invisible. } try { @@ -15660,11 +13570,8 @@ if (typeof module !== 'undefined' && module.exports) { const listeningExams = this.resolveDefaultTypeIndex('listening'); if (!readingExams.length && !listeningExams.length) { - if (global.setExamIndexState) { - global.setExamIndexState([]); - } console.warn('[LibraryManager] 未检测到默认题库脚本中的题源数据'); - this.finishLibraryLoading(startTime); + this.finishLibraryLoading(startTime, []); return []; } @@ -15672,7 +13579,7 @@ if (typeof module !== 'undefined' && module.exports) { if (typeof global.assignExamSequenceNumbers === 'function') { global.assignExamSequenceNumbers(combined); } - const updatedIndex = global.setExamIndexState ? global.setExamIndexState(combined) : combined; + const updatedIndex = combined; const metadata = { source: 'default-script', @@ -15691,22 +13598,19 @@ if (typeof module !== 'undefined' && module.exports) { const overrideMap = this.buildOverridePathMap(metadata, this.DEFAULT_PATH_MAP); - await global.storage.set('exam_index', updatedIndex); - await this.saveLibraryConfiguration('默认题库', 'exam_index', updatedIndex.length); - await this.setActiveLibraryConfiguration('exam_index'); - await this.savePathMapForConfiguration('exam_index', updatedIndex, { setActive: true, overrideMap }); + if (isDefaultConfig) { + await this.setActiveLibraryConfiguration(null); + } + this.setActivePathMap(overrideMap); - this.finishLibraryLoading(startTime); + this.finishLibraryLoading(startTime, updatedIndex); return updatedIndex; } catch (error) { console.error('[LibraryManager] 加载默认题库失败:', error); if (typeof global.showMessage === 'function') { global.showMessage('题库刷新失败: ' + (error && error.message ? error.message : error), 'error'); } - if (global.setExamIndexState) { - global.setExamIndexState([]); - } - this.finishLibraryLoading(startTime); + this.finishLibraryLoading(startTime, []); return []; } } @@ -15730,7 +13634,7 @@ if (typeof module !== 'undefined' && module.exports) { if (entry.trim() === key) { mutated = true; return { - name: key === 'exam_index' ? '默认题库' : key, + name: key, key, examCount, timestamp: now @@ -15748,7 +13652,8 @@ if (typeof module !== 'undefined' && module.exports) { return entry; }); if (mutated) { - await global.storage.set('exam_index_configurations', updated); + const target = updated.find((entry) => entry && entry.key === key); + if (target) await global.AppData.library.updateConfiguration(target); } } catch (error) { console.warn('[LibraryManager] 无法刷新题库配置元数据', error); @@ -15756,11 +13661,10 @@ if (typeof module !== 'undefined' && module.exports) { } async fetchLibraryDataset(key) { - if (!key) { - return []; - } try { - const dataset = await global.storage.get(key); + const dataset = !key + ? this.resolveDefaultTypeIndex('reading').concat(this.resolveDefaultTypeIndex('listening')) + : await global.AppData.library.getIndex(key); return Array.isArray(dataset) ? dataset : []; } catch (error) { console.warn('[LibraryManager] 无法读取题库数据:', key, error); @@ -15786,20 +13690,13 @@ if (typeof module !== 'undefined' && module.exports) { async resolveBaseLibraryIndex(activeKey) { let currentIndex = []; - const key = typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : 'exam_index'; + const key = typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : null; try { currentIndex = await this.fetchLibraryDataset(key); } catch (_) { currentIndex = []; } - if (!Array.isArray(currentIndex) || currentIndex.length === 0) { - try { - currentIndex = global.getExamIndexState ? global.getExamIndexState() : []; - } catch (_) { - currentIndex = []; - } - } - if ((!Array.isArray(currentIndex) || currentIndex.length === 0) && key === 'exam_index') { + if ((!Array.isArray(currentIndex) || currentIndex.length === 0) && key === null) { const reading = this.resolveDefaultTypeIndex('reading'); const listening = this.resolveDefaultTypeIndex('listening'); currentIndex = reading.concat(listening); @@ -15823,7 +13720,7 @@ if (typeof module !== 'undefined' && module.exports) { return this.normalizeIndexForCustomConfig(next); } - async buildUniqueImportedConfigKey(prefix = 'exam_index') { + async buildUniqueImportedConfigKey(prefix = 'library_import') { let configs = []; try { configs = await this.getLibraryConfigurations(); @@ -15842,16 +13739,8 @@ if (typeof module !== 'undefined' && module.exports) { if (used.has(key)) { continue; } - try { - const stored = global.storage && typeof global.storage.get === 'function' - ? await global.storage.get(key) - : null; - if (!stored) { - return key; - } - } catch (_) { - return key; - } + const stored = await global.AppData.library.getIndex(key); + if (!stored.length) return key; } return `${prefix}_${now}_${Math.random().toString(36).slice(2, 8)}`; } @@ -15922,7 +13811,7 @@ if (typeof module !== 'undefined' && module.exports) { try { global.assignExamSequenceNumbers(newIndex); } catch (_) { } } - const key = options.key || await this.buildUniqueImportedConfigKey('exam_index'); + const key = options.key || await this.buildUniqueImportedConfigKey('library_import'); const name = options.name || this.buildImportedConfigName(type, mode, options.label); const counts = countIndexTypes(newIndex); const sourceReport = options.discoveryResult && options.discoveryResult.report @@ -15936,22 +13825,23 @@ if (typeof module !== 'undefined' && module.exports) { mode, accepted: additions.length, rejected: sourceReport ? Number(sourceReport.rejected) || 0 : 0, - createdFrom: activeKey || 'exam_index', + createdFrom: activeKey || null, label: options.label || '', timestamp: Date.now() } }; - await global.storage.set(key, newIndex); - const pathFallback = await this.loadPathMapForConfiguration(activeKey || 'exam_index'); + const pathFallback = await this.loadPathMapForConfiguration(activeKey); const pathMap = this.resourceCore && typeof this.resourceCore.derivePathMapFromIndex === 'function' ? this.resourceCore.derivePathMapFromIndex(newIndex, pathFallback || this.DEFAULT_PATH_MAP) : (pathFallback || null); - await this.savePathMapForConfiguration(key, newIndex, { - overrideMap: pathMap, - setActive: options.activate !== false + await global.AppData.library.import({ + id: key, + configuration: Object.assign({}, metadata, { id: key, key, name, examCount: newIndex.length, timestamp: Date.now() }), + index: newIndex, + operationId: options.operationId }); - await this.saveLibraryConfiguration(name, key, newIndex.length, metadata); + if (options.activate !== false) this.setActivePathMap(pathMap); let applied = true; if (options.activate !== false) { @@ -15982,15 +13872,13 @@ if (typeof module !== 'undefined' && module.exports) { return false; } + await this.setActiveLibraryConfiguration(key); const currentPathMap = await this.loadPathMapForConfiguration(key); const pathMap = this.resourceCore && typeof this.resourceCore.derivePathMapFromIndex === 'function' ? this.resourceCore.derivePathMapFromIndex(exams, currentPathMap || this.DEFAULT_PATH_MAP) : (currentPathMap || null); this.setActivePathMap(pathMap); - if (global.setExamIndexState) { - global.setExamIndexState(exams); - } refreshListeningAvailabilityUI(exams); if (typeof global.setBrowseFilterState === 'function') { global.setBrowseFilterState('all', 'all'); @@ -15999,24 +13887,18 @@ if (typeof module !== 'undefined' && module.exports) { global.setFilteredExamsState([]); } - try { - await this.setActiveLibraryConfiguration(key); - } catch (error) { - console.warn('[LibraryManager] 无法写入当前题库配置:', error); - } - await this.updateLibraryConfigurationMetadata(key, exams.length); await this.savePathMapForConfiguration(key, exams, { overrideMap: pathMap, setActive: true }); - try { global.updateSystemInfo && global.updateSystemInfo(); } catch (_) { } - try { global.updateOverview && global.updateOverview(); } catch (_) { } - try { global.loadExamList && global.loadExamList(); } catch (_) { } + try { global.updateSystemInfo && global.updateSystemInfo(exams); } catch (_) { } + try { global.updateOverview && global.updateOverview(exams); } catch (_) { } + try { global.loadExamList && global.loadExamList(exams); } catch (_) { } try { - global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key } })); + global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key, index: cloneArray(exams) } })); } catch (error) { console.warn('[LibraryManager] 题库切换事件派发失败', error); } @@ -16042,10 +13924,6 @@ if (typeof module !== 'undefined' && module.exports) { if (!configKey) { return { deleted: false, reason: 'invalid-key' }; } - if (configKey === 'exam_index') { - return { deleted: false, reason: 'default-config' }; - } - const activeKey = await this.getActiveLibraryConfigurationKey(); if (activeKey === configKey) { return { deleted: false, reason: 'active-config' }; @@ -16088,12 +13966,8 @@ if (typeof module !== 'undefined' && module.exports) { return { deleted: false, reason: 'not-found' }; } - if (!global.storage || typeof global.storage.remove !== 'function') { - return { deleted: false, reason: 'storage-remove-unavailable' }; - } - await global.storage.remove(configKey); + await global.AppData.library.remove(configKey); await this.deletePathMapForConfiguration(configKey); - await global.storage.set('exam_index_configurations', nextConfigs); return { deleted: true, @@ -16103,7 +13977,8 @@ if (typeof module !== 'undefined' && module.exports) { } async loadLibrary(keyOrForceReload) { - if (keyOrForceReload === 'default' || keyOrForceReload === 'exam_index') { + if (keyOrForceReload === 'default' || keyOrForceReload === null) { + await this.setActiveLibraryConfiguration(null); return this.loadActiveLibrary(true); } if (typeof keyOrForceReload === 'string' && keyOrForceReload) { @@ -16124,7 +13999,7 @@ if (typeof module !== 'undefined' && module.exports) { async function switchLibraryConfig(key) { const manager = getInstance(); - const nextKey = key || await manager.getActiveLibraryConfigurationKey() || 'exam_index'; + const nextKey = typeof key === 'string' && key.trim() ? key.trim() : null; return manager.applyLibraryConfiguration(nextKey); } @@ -16132,10 +14007,25 @@ if (typeof module !== 'undefined' && module.exports) { return getInstance().loadLibrary(keyOrForceReload); } + async function resolveActiveLibraryIndex() { + return getInstance().resolveActiveIndex(); + } + + async function resolveLibraryIndexForPracticeRecord(record) { + return getInstance().resolveIndexForRecord(record); + } + + async function resolveExamForPracticeRecord(record) { + return getInstance().resolveExamForRecord(record); + } + global.LibraryManager = { getInstance, switchLibraryConfig, loadLibrary, + resolveActiveIndex: resolveActiveLibraryIndex, + resolveIndexForRecord: resolveLibraryIndexForPracticeRecord, + resolveExamForRecord: resolveExamForPracticeRecord, get RAW_DEFAULT_PATH_MAP() { const manager = getInstance(); return manager.RAW_DEFAULT_PATH_MAP; @@ -16174,6 +14064,9 @@ if (typeof module !== 'undefined' && module.exports) { global.isBuiltInListeningLibraryAvailable = isBuiltInListeningLibraryAvailable; global.switchLibraryConfig = switchLibraryConfig; global.loadLibrary = loadLibrary; + global.resolveActiveLibraryIndex = resolveActiveLibraryIndex; + global.resolveLibraryIndexForPracticeRecord = resolveLibraryIndexForPracticeRecord; + global.resolveExamForPracticeRecord = resolveExamForPracticeRecord; })(typeof window !== 'undefined' ? window : globalThis); @@ -16183,25 +14076,15 @@ if (typeof module !== 'undefined' && module.exports) { global.AppLazyLoader.markProvided([ "js/utils/environmentDetector.js", "js/utils/logger.js", - "js/utils/storage.js", - "js/core/storageProviderRegistry.js", - "js/data/dataSources/storageDataSource.js", - "js/data/repositories/baseRepository.js", - "js/data/repositories/dataRepositoryRegistry.js", - "js/data/repositories/practiceRepository.js", - "js/data/repositories/settingsRepository.js", - "js/data/repositories/backupRepository.js", - "js/data/repositories/metaRepository.js", - "js/data/index.js", - "js/core/practiceCore.js", - "js/core/practiceRecordAPI.js", - "js/core/backupAPI.js", + "js/data/practiceRecordSource.js", + "js/data/v2/dataCatalog.js", + "js/data/v2/dataKernel.js", + "js/data/v2/appData.js", "js/core/externalBackupService.js", - "js/core/practiceStore.js", + "js/core/siteDataReset.js", + "js/core/practiceCore.js", "js/core/resourceCore.js", "assets/generated/reading-exams/manifest.js", - "js/utils/stateSerializer.js", - "js/utils/simpleStorageWrapper.js", "js/app/state-service.js", "js/services/libraryDiscovery.js", "js/services/libraryManager.js" diff --git a/js/bundles/diagnostics.bundle.js b/js/bundles/diagnostics.bundle.js index 0bdd4088..c59120c3 100644 --- a/js/bundles/diagnostics.bundle.js +++ b/js/bundles/diagnostics.bundle.js @@ -125,8 +125,11 @@ class SystemDiagnostics { /** * 测试单个题目的通信功能 */ - async testExamCommunication(examId, timeout = 10000) { - const exam = window.examIndex?.find(e => e.id === examId); + async testExamCommunication(examId, timeout = 10000, examIndex = null) { + const index = Array.isArray(examIndex) + ? examIndex + : await window.resolveActiveLibraryIndex(); + const exam = index.find(e => e.id === examId); if (!exam) { return { examId, @@ -179,7 +182,10 @@ class SystemDiagnostics { } }; - examWindow.postMessage(testMessage, '*'); + examWindow.postMessage( + testMessage, + window.location.protocol === 'file:' ? '*' : window.location.origin + ); // 等待响应 const result = await new Promise((resolve) => { @@ -237,14 +243,17 @@ class SystemDiagnostics { /** * 批量测试通信功能 */ - async testMultipleExams(examIds, concurrency = 3) { + async testMultipleExams(examIds, concurrency = 3, examIndex = null) { console.log(`[SystemDiagnostics] 开始批量测试 ${examIds.length} 个题目的通信功能`); + const index = Array.isArray(examIndex) + ? examIndex + : await window.resolveActiveLibraryIndex(); const results = []; for (let i = 0; i < examIds.length; i += concurrency) { const batch = examIds.slice(i, i + concurrency); const batchResults = await Promise.all( - batch.map(examId => this.testExamCommunication(examId)) + batch.map(examId => this.testExamCommunication(examId, 10000, index)) ); results.push(...batchResults); } @@ -298,7 +307,7 @@ class SystemDiagnostics { connection.window.postMessage({ type: 'HEARTBEAT', timestamp: Date.now() - }, '*'); + }, window.location.protocol === 'file:' ? '*' : window.location.origin); } } catch (error) { this.handleConnectionLost(examId, 'connection_error'); @@ -526,7 +535,7 @@ class SystemDiagnostics { async fullSystemDiagnostics() { console.log('[SystemDiagnostics] 开始完整系统诊断...'); - const examIndex = window.examIndex || []; + const examIndex = await window.resolveActiveLibraryIndex(); const diagnosticReport = { timestamp: Date.now(), indexValidation: null, @@ -543,7 +552,7 @@ class SystemDiagnostics { // 如果有失败的题目,进行通信测试 if (diagnosticReport.indexValidation.failedExams.length > 0) { const failedExamIds = diagnosticReport.indexValidation.failedExams.map(exam => exam.id); - diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5)); // 限制测试数量 + diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5), 3, examIndex); // 限制测试数量 } } catch (error) { console.error('[SystemDiagnostics] 索引验证失败:', error); @@ -1142,6 +1151,7 @@ class PerformanceOptimizer { return function executedFunction(...args) { const later = () => { clearTimeout(timeout); + timeout = null; func(...args); }; clearTimeout(timeout); diff --git a/js/bundles/legacy-app.bundle.js b/js/bundles/legacy-app.bundle.js index 7bd8c2a2..352a77b9 100644 --- a/js/bundles/legacy-app.bundle.js +++ b/js/bundles/legacy-app.bundle.js @@ -40,7 +40,6 @@ }); } - var storage = window.storage; // Fallback for navigation if (typeof window.showView !== 'function') { window.showView = function (viewName, resetCategory) { @@ -101,7 +100,6 @@ if (normalized === 'practice' && typeof window.ensurePracticeRecordsSync === 'function') { window.ensurePracticeRecordsSync('practice-view').catch(function () { }); } - if (normalized === 'practice' && typeof window.updatePracticeView === 'function') window.updatePracticeView(); }; } @@ -145,53 +143,24 @@ return fn.name === 'lazyProxy' || src.indexOf('ensureLazyGroup') !== -1 || src.indexOf('AppLazyLoader') !== -1; }; - function _ensureFallbackDataIntegrityManager() { - if (!window.dataIntegrityManager && window.DataIntegrityManager) { - try { - window.dataIntegrityManager = new window.DataIntegrityManager(); - } catch (error) { - console.warn('[Fallback] 初始化 DataIntegrityManager 失败:', error); - } - } - return window.dataIntegrityManager || null; + function _fallbackDownloadJson(data, filename) { + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json; charset=utf-8' }); + var url = URL.createObjectURL(blob); + var anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); } - var _fallbackDataIntegrityLoadPromise = null; - - function _ensureFallbackDataIntegrityManagerAsync() { - var manager = _ensureFallbackDataIntegrityManager(); - if (manager) { - return Promise.resolve(manager); - } - - if (!_fallbackDataIntegrityLoadPromise) { - if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { - _fallbackDataIntegrityLoadPromise = window.AppLazyLoader.ensureGroup('settings-tools'); - } else if (typeof document !== 'undefined' && !window.DataIntegrityManager) { - _fallbackDataIntegrityLoadPromise = new Promise(function (resolve, reject) { - var script = document.createElement('script'); - script.src = 'js/components/DataIntegrityManager.js'; - script.onload = resolve; - script.onerror = function (error) { - reject(error || new Error('failed to load DataIntegrityManager')); - }; - document.head.appendChild(script); - }); - } else { - _fallbackDataIntegrityLoadPromise = Promise.resolve(); - } - } - - return _fallbackDataIntegrityLoadPromise.then(function () { - var readyManager = _ensureFallbackDataIntegrityManager(); - if (!readyManager) { - throw new Error('数据管理模块未初始化'); - } - return readyManager; - }).catch(function (error) { - _fallbackDataIntegrityLoadPromise = null; - throw error; - }); + async function _fallbackExportAllData() { + await window.AppData.ready; + var snapshot = await window.AppData.backups.export(); + _fallbackDownloadJson(snapshot, 'ielts-atlas-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'); + try { await window.AppData.backups.recordExport({ type: 'full-v2', checksum: snapshot.checksum }); } catch (error) { console.warn('[Fallback] 导出历史记录失败:', error); } + return snapshot; } function _fallbackCreateElement(tag, attributes, children) { @@ -294,21 +263,13 @@ return; } - var manager = null; - try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - if (!confirm('确定要恢复备份 ' + backupId + ' 吗?当前数据将被覆盖。')) { return; } try { window.showMessage && window.showMessage('正在恢复备份...', 'info'); - await manager.restoreBackup(backupId); + await window.AppData.backups.restore(backupId); window.showMessage && window.showMessage('备份恢复成功', 'success'); setTimeout(function () { try { @@ -387,30 +348,6 @@ }; } - var ensureDataBackupManager = (function () { - let loading = null; - return function ensureDataBackupManager() { - if (window.DataBackupManager) { - return Promise.resolve(new window.DataBackupManager()); - } - if (loading) { - return loading.then(() => new window.DataBackupManager()); - } - if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') { - loading = window.AppLazyLoader.ensureGroup('settings-tools'); - return loading.then(() => new window.DataBackupManager()); - } - loading = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'js/utils/dataBackupManager.js'; - script.onload = () => resolve(); - script.onerror = (err) => reject(err || new Error('failed to load dataBackupManager')); - document.head.appendChild(script); - }); - return loading.then(() => new window.DataBackupManager()); - }; - })(); - function showImportModeModal(onSelect) { const overlay = document.createElement('div'); overlay.className = 'import-mode-overlay-lite'; @@ -434,7 +371,7 @@ const defs = [ { mode: 'merge', icon: '📥', title: '增量导入', text: '合并新数据,保留现有记录。适合日常更新。' }, - { mode: 'replace', icon: '⚠️', title: '覆盖导入', text: '清空并替换所有记录。慎用,数据不可恢复。' } + { mode: 'replace', icon: '⚠️', title: '覆盖练习记录', text: '仅用文件中的练习记录替换现有记录;提交前会显示删除数量。' } ]; defs.forEach((def) => { @@ -597,12 +534,29 @@ return; } try { - const manager = await ensureDataBackupManager(); - const result = await manager.importPracticeData(data, { - mergeMode: mode === 'replace' ? 'replace' : 'merge', - createBackup: true, - validateData: true + const payload = Array.isArray(data) ? { records: data } : data; + const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mode === 'replace' ? 'replace' : 'merge' }); + if (preview.destructive) { + const practice = preview.practice || {}; + const summary = [ + '这次导入会删除现有数据。', + `练习记录:现有 ${Number(practice.existingCount) || 0} 条 → 导入后 ${Number(practice.finalCount) || 0} 条`, + `将删除 ${Number(practice.removedCount) || 0} 条。` + ]; + if (Array.isArray(preview.clearedKeys) && preview.clearedKeys.length) { + summary.push(`将清空数据域:${preview.clearedKeys.join('、')}`); + } + summary.push('', '是否确认继续?'); + if (!window.confirm(summary.join('\n'))) { + window.showMessage && window.showMessage('已取消导入,现有数据未改变', 'info'); + return; + } + } + const backup = await window.AppData.backups.create({ type: 'pre-import' }); + const result = await window.AppData.backups.commitImport(preview.id, { + confirmDestructive: preview.destructive === true }); + try { await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup.id, practice: preview.practice }); } catch (historyError) { console.warn('[Fallback] 导入历史记录失败:', historyError); } window.showMessage && window.showMessage(`导入成功:新增 ${result.importedCount || 0} 条,跳过 ${result.skippedCount || 0} 条。`, 'success'); } catch (error) { console.error('[importData] failed', error); @@ -614,17 +568,8 @@ if (typeof window.exportAllData !== 'function') { window.exportAllData = async function () { - var manager = null; try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - console.error('[Fallback] 数据导出模块加载失败:', error); - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - - try { - await manager.exportData(); + await _fallbackExportAllData(); window.showMessage && window.showMessage('数据导出成功', 'success'); } catch (error) { console.error('[Fallback] 数据导出失败:', error); @@ -659,25 +604,14 @@ // Fallbacks for backup operations used by Settings if (typeof window.createManualBackup !== 'function') { window.createManualBackup = async function () { - var manager = null; - try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } try { - var backup = await manager.createBackup(null, 'manual'); - if (backup && backup.external) { - window.showMessage && window.showMessage('本地存储不足,已将备份下载为文件', 'warning'); - } else { - window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success'); - } + var backup = await window.AppData.backups.create({ type: 'manual' }); + window.showMessage && window.showMessage('备份创建成功: ' + (backup && backup.id ? backup.id : ''), 'success'); try { if (typeof window.showBackupList === 'function') { window.showBackupList(); } } catch (_) { } } catch (error) { if (_fallbackIsQuotaExceeded(error)) { try { - await manager.exportData(); + await _fallbackExportAllData(); window.showMessage && window.showMessage('存储不足:已将数据导出为文件', 'warning'); } catch (exportErr) { window.showMessage && window.showMessage('备份失败且导出失败: ' + (exportErr && exportErr.message ? exportErr.message : exportErr), 'error'); @@ -691,18 +625,10 @@ if (typeof window.showBackupList !== 'function') { window.showBackupList = async function () { - var manager = null; - try { - manager = await _ensureFallbackDataIntegrityManagerAsync(); - } catch (error) { - window.showMessage && window.showMessage((error && error.message) || '数据管理模块未初始化', 'error'); - return; - } - _ensureFallbackBackupDelegates(); var backups = []; try { - backups = await manager.getBackupList(); + backups = await window.AppData.backups.list(); } catch (error) { console.warn('[Fallback] 获取备份列表失败:', error); window.showMessage && window.showMessage('无法获取备份列表', 'error'); @@ -801,38 +727,11 @@ async function ensureDefaultConfig() { try { - var configs = []; - if (window.storage && storage.get) { - var maybeConfigs = storage.get('exam_index_configurations', []); - configs = (maybeConfigs && typeof maybeConfigs.then === 'function') ? await maybeConfigs : maybeConfigs; - } + var configs = await window.AppData.library.listConfigurations(); if (!Array.isArray(configs)) configs = []; - var hasDefault = configs.some(function (c) { return c && c.key === 'exam_index'; }); - if (!hasDefault) { - var count = Array.isArray(window.examIndex) ? window.examIndex.length : 0; - configs.push({ name: '默认题库', key: 'exam_index', examCount: count, timestamp: Date.now() }); - if (window.storage && storage.set) { - try { - var maybeSetConfigs = storage.set('exam_index_configurations', configs); - if (maybeSetConfigs && typeof maybeSetConfigs.then === 'function') await maybeSetConfigs; - } catch (err) { - console.warn('[Fallback] 无法保存 exam_index_configurations:', err); - } - } - if (window.storage && storage.get) { - try { - var currentActive = storage.get('active_exam_index_key'); - currentActive = (currentActive && typeof currentActive.then === 'function') ? await currentActive : currentActive; - if (!currentActive && window.storage && storage.set) { - var maybeSetActive = storage.set('active_exam_index_key', 'exam_index'); - if (maybeSetActive && typeof maybeSetActive.then === 'function') await maybeSetActive; - } - } catch (activeErr) { - console.warn('[Fallback] 无法校正 active_exam_index_key:', activeErr); - } - } - } - return configs; + var activeIndex = await window.resolveActiveLibraryIndex(); + var count = Array.isArray(activeIndex) ? activeIndex.length : 0; + return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest', examCount: count }].concat(configs); } catch (e) { console.warn('[Fallback] ensureDefaultConfig 失败:', e); return []; @@ -859,23 +758,18 @@ window.showLibraryConfigListV2 = async function (options) { var configs = []; try { - configs = (window.storage && storage.get) ? await storage.get('exam_index_configurations', []) : []; + configs = await ensureDefaultConfig(); } catch (e) { configs = []; } - if (!Array.isArray(configs) || configs.length === 0) { - configs = await ensureDefaultConfig(); - } if (!Array.isArray(configs) || configs.length === 0) { if (window.showMessage) showMessage('暂无题库配置记录', 'info'); return; } - var activeKey = 'exam_index'; + var activeKey = null; try { - if (window.storage && storage.get) { - activeKey = await storage.get('active_exam_index_key', 'exam_index'); - } + activeKey = await window.AppData.library.getActive(); } catch (e) { } var containerId = options && typeof options.containerId === 'string' ? options.containerId : null; @@ -918,12 +812,14 @@ configs.forEach(function (cfg) { if (!cfg) return; var item = document.createElement('div'); - item.className = 'library-config-panel__item' + (cfg.key === activeKey ? ' library-config-panel__item--active' : ''); + var isDefault = cfg.builtIn === true; + var isActive = isDefault ? activeKey == null : cfg.key === activeKey; + item.className = 'library-config-panel__item' + (isActive ? ' library-config-panel__item--active' : ''); var info = document.createElement('div'); info.className = 'library-config-panel__info'; var titleLine = document.createElement('div'); - titleLine.textContent = (cfg.key === 'exam_index' ? '默认题库' : (cfg.name || cfg.key)); + titleLine.textContent = (isDefault ? '默认题库' : (cfg.name || cfg.key)); info.appendChild(titleLine); var meta = document.createElement('div'); @@ -941,18 +837,18 @@ switchBtn.className = 'btn btn-secondary'; switchBtn.type = 'button'; switchBtn.dataset.configAction = 'switch'; - switchBtn.dataset.configKey = cfg.key; - if (cfg.key === activeKey) switchBtn.disabled = true; + switchBtn.dataset.configKey = cfg.key || ''; + if (isActive) switchBtn.disabled = true; switchBtn.textContent = '切换'; actions.appendChild(switchBtn); - if (cfg.key !== 'exam_index') { + if (!isDefault) { var deleteBtn = document.createElement('button'); deleteBtn.className = 'btn btn-warning'; deleteBtn.type = 'button'; deleteBtn.dataset.configAction = 'delete'; - deleteBtn.dataset.configKey = cfg.key; - if (cfg.key === activeKey) deleteBtn.disabled = true; + deleteBtn.dataset.configKey = cfg.key || ''; + if (isActive) deleteBtn.disabled = true; deleteBtn.textContent = '删除'; actions.appendChild(deleteBtn); } @@ -1329,29 +1225,14 @@ if (typeof window.getActiveLibraryConfigurationKey === 'function') { try { return await window.getActiveLibraryConfigurationKey(); } catch (_) { } } - if (storage && storage.get) { - try { - var maybeKey = storage.get('active_exam_index_key', 'exam_index'); - var key = (maybeKey && typeof maybeKey.then === 'function') ? await maybeKey : maybeKey; - return key || 'exam_index'; - } catch (_) { } - } - return 'exam_index'; + return window.AppData.library.getActive(); } async function _fallbackSetActiveLibraryKey(key) { - if (!key) return; if (typeof window.setActiveLibraryConfiguration === 'function') { try { await window.setActiveLibraryConfiguration(key); return; } catch (_) { } } - if (storage && storage.set) { - try { - var maybe = storage.set('active_exam_index_key', key); - if (maybe && typeof maybe.then === 'function') await maybe; - } catch (err) { - console.warn('[Fallback] 无法写入 active_exam_index_key:', err); - } - } + await window.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null); } async function _fallbackSaveLibraryConfiguration(name, key, count) { @@ -1359,51 +1240,28 @@ if (typeof window.saveLibraryConfiguration === 'function') { try { await window.saveLibraryConfiguration(name, key, count); return; } catch (_) { } } - if (storage && storage.get && storage.set) { - try { - var existing = storage.get('exam_index_configurations', []); - existing = (existing && typeof existing.then === 'function') ? await existing : existing; - if (!Array.isArray(existing)) existing = []; - var idx = existing.findIndex(function (c) { return c && c.key === key; }); - if (idx >= 0) { existing[idx] = entry; } else { existing.push(entry); } - var maybeSave = storage.set('exam_index_configurations', existing); - if (maybeSave && typeof maybeSave.then === 'function') await maybeSave; - } catch (err) { - console.warn('[Fallback] 保存题库配置失败:', err); - } - } + if (key) await window.AppData.library.updateConfiguration(entry); } async function _fallbackSaveIndexForKey(key, list) { - if (storage && storage.set) { - var maybe = storage.set(key, list); - if (maybe && typeof maybe.then === 'function') { - await maybe; - } - } else { - try { window[key] = list; } catch (_) { } - } + if (key) await window.AppData.library.import({ id: key, configuration: { id: key, key: key, name: key }, index: list }); } async function _fallbackApplyLibraryConfig(key, dataset, options) { if (typeof window.applyLibraryConfiguration === 'function') { try { return await window.applyLibraryConfiguration(key, dataset, options || {}); } catch (_) { } } - // fallback:直接刷新内存状态与UI - if (typeof window.setExamIndexState === 'function') { - try { window.setExamIndexState(dataset); } catch (_) { } - } else { - try { window.examIndex = Array.isArray(dataset) ? dataset.slice() : []; } catch (_) { } - } + var snapshot = Array.isArray(dataset) ? dataset.slice() : []; if (options && options.setActive) { await _fallbackSetActiveLibraryKey(key); } - try { if (typeof window.updateOverview === 'function') window.updateOverview(); } catch (_) { } + try { if (typeof window.updateOverview === 'function') window.updateOverview(snapshot); } catch (_) { } try { if (typeof window.loadExamList === 'function') { - window.loadExamList(); + window.loadExamList(snapshot); } } catch (_) { } + try { window.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key: key, index: snapshot } })); } catch (_) { } return true; } @@ -1614,15 +1472,7 @@ } var activeKey = await _fallbackGetActiveLibraryKey(); - var currentIndex = (typeof window.getExamIndexState === 'function') - ? window.getExamIndexState() - : (Array.isArray(window.examIndex) ? window.examIndex : []); - if (storage && storage.get) { - try { - var maybeCurrent = storage.get(activeKey, currentIndex); - currentIndex = (maybeCurrent && typeof maybeCurrent.then === 'function') ? await maybeCurrent : maybeCurrent; - } catch (_) { } - } + var currentIndex = await window.resolveActiveLibraryIndex(); if (!Array.isArray(currentIndex)) currentIndex = []; currentIndex = _fallbackNormalizeIndexForCustomConfig(currentIndex); @@ -1665,7 +1515,7 @@ }; if (mode === 'full') { - var targetKey = 'exam_index_' + Date.now(); + var targetKey = 'library_import_' + Date.now(); var configName = (type === 'reading' ? '阅读' : '听力') + '全量-' + new Date().toLocaleString(); try { await saveAndApply(targetKey, configName, true); @@ -1693,7 +1543,7 @@ } } - var targetKeyInc = 'exam_index_' + Date.now(); + var targetKeyInc = 'library_import_' + Date.now(); var configNameInc = (type === 'reading' ? '阅读' : '听力') + '增量-' + new Date().toLocaleString(); await saveAndApply(targetKeyInc, configNameInc, false); await _fallbackApplyLibraryConfig(targetKeyInc, newIndex, { setActive: true, skipConfigRefresh: false }); @@ -1758,118 +1608,6 @@ })(); -/* ===== js/patches/runtime-fixes.js ===== */ -// Runtime fixes to smooth async storage + recovery under file:// -(function () { - 'use strict'; - - function ensureCompatPatch(global) { - if (!global || (global.CompatPatch && typeof global.CompatPatch.register === 'function')) { - return global && global.CompatPatch ? global.CompatPatch : null; - } - var patches = []; - var register = function register(name, metadata) { - if (!name) { - return null; - } - var patch = Object.assign({ - name: String(name), - owner: 'legacy', - reason: '', - removeAfter: '' - }, metadata || {}); - patches.push(patch); - return patch; - }; - var list = function list() { - return patches.slice(); - }; - global.CompatPatch = Object.assign({}, global.CompatPatch || {}, { - register: register, - list: list - }); - return global.CompatPatch; - } - - ensureCompatPatch(window); - - if (window.CompatPatch && typeof window.CompatPatch.register === 'function') { - window.CompatPatch.register('practice-recorder-temp-recovery-async', { - owner: 'practice', - reason: 'file protocol compatible recovery for legacy temporary practice records', - removeAfter: 'after PracticeRecorder recovery is canonical' - }); - } - - try { - // Patch PracticeRecorder.recoverTemporaryRecords to a robust async version - const patchPracticeRecorder = () => { - const PR = window.PracticeRecorder; - if (!PR || !PR.prototype) return false; - - const original = PR.prototype.recoverTemporaryRecords; - PR.prototype.recoverTemporaryRecords = async function () { - try { - const raw = (window.storage && storage.get) - ? await storage.get('temp_practice_records', []) - : []; - const tempRecords = Array.isArray(raw) ? raw : []; - - if (tempRecords.length === 0) { - console.log('[PracticeRecorder] 没有需要恢复的临时记录'); - return; - } - - console.log(`[PracticeRecorder] 发现 ${tempRecords.length} 条临时记录,开始恢复...`); - - let recoveredCount = 0; - const failed = []; - - for (const tempRecord of tempRecords) { - try { - const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord || {}; - const sanitized = (this && typeof this.sanitizeRecoveredRecord === 'function') - ? this.sanitizeRecoveredRecord(cleanRecord) - : cleanRecord; - if (!sanitized || !sanitized.examId) { - console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord && cleanRecord.id); - continue; - } - if (this && typeof this.savePracticeRecord === 'function') { - await this.savePracticeRecord(sanitized); - } - recoveredCount++; - console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized && sanitized.id}`); - } catch (e) { - console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord && tempRecord.id}`, e); - failed.push(tempRecord); - } - } - - if (failed.length === 0) { - if (window.storage && storage.remove) await storage.remove('temp_practice_records'); - console.log(`[PracticeRecorder] 所有 ${recoveredCount} 条临时记录恢复成功`); - } else { - if (window.storage && storage.set) await storage.set('temp_practice_records', failed); - console.log(`[PracticeRecorder] 恢复了 ${recoveredCount} 条记录,${failed.length} 条失败`); - } - } catch (error) { - console.error('[PracticeRecorder] 恢复临时记录时出错:', error); - } - }; - - console.log('[RuntimeFixes] PracticeRecorder.recoverTemporaryRecords 已替换为异步实现'); - return true; - }; - - const tryPatch = () => { - if (!patchPracticeRecorder()) setTimeout(tryPatch, 100); - }; - tryPatch(); - } catch (_) {} -})(); - - /* ===== js/app.js ===== */ /** * 主应用程序 @@ -1886,17 +1624,14 @@ class ExamSystemApp { this.state = { // 考试相关状态 exam: { - index: [], currentCategory: 'all', currentExamType: 'all', filteredExams: [], - configurations: {}, - activeConfigKey: 'exam_index' + configurations: {} }, // 练习相关状态 practice: { - records: [], selectedRecords: new Set(), bulkDeleteMode: false, dataCollector: null @@ -1915,7 +1650,6 @@ class ExamSystemApp { // 组件实例 components: { - dataIntegrityManager: null, pdfHandler: null, browseStateManager: null, practiceListScroller: null @@ -1952,62 +1686,6 @@ class ExamSystemApp { const current = this.getState(path); this.setState(path, { ...current, ...updates }); }, - async persistState(path, storageKey = null) { - const value = this.getState(path); - const key = storageKey || path.replace('.', '_'); - try { - const serializedValue = StateSerializer.serialize(value); - await storage.set(key, serializedValue); - } catch (error) { - console.error(`[App] 持久化状态失败 ${path}:`, error); - } - }, - async persistMultipleState(mapping) { - const promises = Object.entries(mapping).map(([path, storageKey]) => - this.persistState(path, storageKey) - ); - try { - await Promise.all(promises); - } catch (error) { - console.error('[App] 批量持久化状态失败:', error); - } - }, - async loadState(path, storageKey = null) { - const key = storageKey || path.replace('.', '_'); - try { - const value = await storage.get(key, null); - if (value !== null) { - const deserializedValue = StateSerializer.deserialize(value); - this.setState(path, deserializedValue); - return deserializedValue; - } - } catch (error) { - console.error(`[App] 加载状态失败 ${path}:`, error); - } - return null; - }, - async loadPersistedState() { - const stateMappings = { - exam: 'app_exam_state', - practice: 'app_practice_state', - ui: 'app_ui_state', - system: 'app_system_state' - }; - for (const [path, storageKey] of Object.entries(stateMappings)) { - await this.loadState(path, storageKey); - } - console.log('[App] 持久化状态加载完成'); - }, - async saveAllState() { - const stateMappings = { - exam: 'app_exam_state', - practice: 'app_practice_state', - ui: 'app_ui_state', - system: 'app_system_state' - }; - await this.persistMultipleState(stateMappings); - console.log('[App] 所有状态已保存'); - }, async checkComponents() { console.log('=== 组件加载检查 ==='); try { @@ -2046,12 +1724,9 @@ class ExamSystemApp { console.log(`${name}: ${status}`); }); console.log('\n=== 数据检查 ==='); - const practiceRecordsCount = this.getState('practice.records')?.length || 0; - console.log(`practiceRecords: ${practiceRecordsCount} 条记录`); try { - const records = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function' - ? await window.PracticeRecordAPI.list() - : []; + // 只统计条数,light 投影即可,避免为诊断日志拉取全量答题详情。 + const records = await window.AppData.practice.list({ projection: 'light' }); const count = Array.isArray(records) ? records.length : 0; console.log(`canonical practice records: ${count} 条记录`); } catch (_) { @@ -2078,11 +1753,6 @@ class ExamSystemApp { console.warn('[App] AppStateService connect failed:', error); } } - Object.defineProperty(window, 'dataIntegrityManager', { - get: () => this.state.components.dataIntegrityManager, - set: (value) => this.setState('components.dataIntegrityManager', value), - configurable: true - }); Object.defineProperty(window, 'pdfHandler', { get: () => this.state.components.pdfHandler, set: (value) => this.setState('components.pdfHandler', value), @@ -2099,7 +1769,7 @@ class ExamSystemApp { const integratedBootstrapMixin = { checkDependencies() { - const requiredGlobals = ['storage']; + const requiredGlobals = ['AppData']; const missing = requiredGlobals.filter((name) => !window[name]); if (missing.length > 0) { throw new Error(`Missing required dependencies: ${missing.join(', ')}`); @@ -2126,6 +1796,11 @@ class ExamSystemApp { }, async initializeCoreComponents() { if (this.instantiatePracticeRecorder()) { + // PracticeRecorder restores durable sessions asynchronously. The + // hot-upgrade rebind must run after that restore has completed; + // otherwise the recovery snapshot can overwrite the host session + // that we are about to seed. + await this._practiceRecorderRebindPromise; return; } console.warn('[App] PracticeRecorder类不可用,使用降级记录器'); @@ -2138,14 +1813,119 @@ class ExamSystemApp { return false; } try { - this.components.practiceRecorder = new PracticeRecorder(); + const previous = this.components && this.components.practiceRecorder + ? this.components.practiceRecorder + : null; + if (previous && previous.constructor === window.PracticeRecorder && previous.isFallback !== true) { + return true; + } + const recorder = new PracticeRecorder(); + this.components.practiceRecorder = recorder; this.ensurePracticeRecorderEvents(); + // Hot-upgrade from the bootstrap fallback must re-seed live host sessions; + // otherwise PRACTICE_COMPLETE finds no activeSessions and production rejects + // synthetic saves, so the child never receives PRACTICE_SUBMIT_ACK / results. + const recorderReady = recorder.ready && typeof recorder.ready.then === 'function' + ? recorder.ready + : Promise.resolve(); + this._practiceRecorderRebindPromise = Promise.resolve(recorderReady) + .then(() => this._rebindPracticeRecorderSessions(recorder, previous)) + .catch((rebindError) => { + console.warn('[App] PracticeRecorder ready 后重建活动会话失败:', rebindError); + }); return true; } catch (error) { console.error('[App] PracticeRecorder初始化失败:', error); return false; } }, + _rebindPracticeRecorderSessions(recorder, previousRecorder = null) { + if (!recorder || typeof recorder.startPracticeSession !== 'function') { + return; + } + const seeded = new Set(); + try { + if (this.examWindows && typeof this.examWindows.forEach === 'function') { + this.examWindows.forEach((info, examId) => { + if (!info || !examId) { + return; + } + if (info.reviewMode || String(info.practiceMode || '').toLowerCase() === 'memorize') { + return; + } + if (info.status === 'completed' || info.status === 'closed') { + return; + } + const sessionId = info.expectedSessionId || info.sessionId || null; + if (!sessionId) { + return; + } + try { + recorder.startPracticeSession(examId, { + sessionId: String(sessionId), + title: info.title || info.examTitle || '', + category: info.category || info.pageType || '', + frequency: info.frequency || '', + libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId') + ? info.libraryConfigurationId + : (typeof this._readLaunchLibraryConfigurationId === 'function' + ? this._readLaunchLibraryConfigurationId(examId, null, info) + : null) + }); + if (typeof recorder.handleSessionStarted === 'function') { + recorder.handleSessionStarted({ + examId, + sessionId: String(sessionId), + metadata: { + pageType: info.pageType || null, + suiteSessionId: info.suiteSessionId || null, + source: 'recorder-hot-upgrade', + libraryConfigurationId: Object.prototype.hasOwnProperty.call(info, 'libraryConfigurationId') + ? info.libraryConfigurationId + : null + } + }); + } + seeded.add(String(examId)); + } catch (seedError) { + console.warn('[App] 升级 PracticeRecorder 时重建活动会话失败:', examId, seedError); + } + }); + } + } catch (error) { + console.warn('[App] 升级 PracticeRecorder 时扫描 examWindows 失败:', error); + } + + // Carry over any sessions the fallback stub tracked in-memory before the class loaded. + try { + const priorSessions = previousRecorder && previousRecorder.activeSessions; + if (priorSessions && typeof priorSessions.forEach === 'function') { + priorSessions.forEach((session, examId) => { + if (!examId || seeded.has(String(examId)) || !session) { + return; + } + const sessionId = session.sessionId || session.id || null; + if (!sessionId) { + return; + } + try { + recorder.startPracticeSession(examId, Object.assign({}, session.metadata || {}, { + sessionId: String(sessionId), + title: session.metadata && (session.metadata.examTitle || session.metadata.title) || '', + totalQuestions: session.progress && session.progress.totalQuestions || 0, + libraryConfigurationId: session.metadata && session.metadata.libraryConfigurationId != null + ? session.metadata.libraryConfigurationId + : null + })); + } catch (seedError) { + console.warn('[App] 升级 PracticeRecorder 时迁移降级会话失败:', examId, seedError); + } + }); + } + } catch (error) { + console.warn('[App] 升级 PracticeRecorder 时读取降级会话失败:', error); + } + }, ensurePracticeRecorderEvents() { if (this._practiceRecorderEventsBound) { return; @@ -2155,36 +1935,62 @@ class ExamSystemApp { } }, createFallbackRecorder() { - function normalizeRecords(records) { - return Array.isArray(records) ? records : []; - } + const activeSessions = new Map(); + const start = (examId, examData = {}) => { + const sessionId = (examData && examData.sessionId) + || `fallback_${examId || 'exam'}_${Date.now()}`; + const session = { + examId: examId || '', + startTime: new Date().toISOString(), + sessionId, + status: 'started', + progress: { + totalQuestions: examData && examData.totalQuestions || 0 + }, + metadata: { + examTitle: examData && examData.title || '', + category: examData && examData.category || '', + frequency: examData && examData.frequency || '', + libraryConfigurationId: examData && examData.libraryConfigurationId != null + ? examData.libraryConfigurationId + : null + } + }; + if (examId) { + activeSessions.set(examId, session); + } + return session; + }; return { - startPracticeSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }), - startSession: (examId) => ({ examId: examId || '', startTime: Date.now(), sessionId: `fallback_${Date.now()}`, status: 'started' }), + activeSessions, + isFallback: true, + startPracticeSession: start, + startSession: start, + handleSessionStarted: (data) => { + if (!data || !data.examId || !data.sessionId) { + return; + } + const existing = activeSessions.get(data.examId) || { + examId: data.examId, + startTime: new Date().toISOString(), + status: 'started', + metadata: {} + }; + existing.sessionId = data.sessionId; + existing.status = 'active'; + if (data.metadata) { + existing.metadata = Object.assign({}, existing.metadata || {}, data.metadata); + } + activeSessions.set(data.examId, existing); + }, handleRealPracticeData: async () => null, savePracticeRecord: async (record) => { - try { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.saveRecord === 'function') { - await window.PracticeRecordAPI.saveRecord(record); - } else { - throw new Error('统一练习记录存储未就绪'); - } - } catch (error) { - console.warn('[App] 降级记录器保存失败:', error); - } - return record || null; + const receipt = await window.AppData.practice.completeAttempt({ record }); + return receipt && receipt.record ? receipt.record : null; }, - getPracticeRecords: async () => { - try { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return normalizeRecords(await window.PracticeRecordAPI.list()); - } - return []; - } catch (error) { - console.warn('[App] 降级记录器读取失败:', error); - return []; - } - } + // 兼容用的记录列表读取:调用方只做列表/统计展示,light 投影已覆盖, + // 不需要拉取答题详情、笔记与高亮等重负载字段。 + getPracticeRecords: async () => window.AppData.practice.list({ projection: 'light' }) }; }, schedulePracticeRecorderUpgrade(maxAttempts = 20, interval = 500) { @@ -2510,11 +2316,17 @@ class ExamSystemApp { case 'browse': if (window.__pendingBrowseFilter && typeof window.applyBrowseFilter === 'function') { const { category, type, filterMode, path } = window.__pendingBrowseFilter; - try { - window.applyBrowseFilter(category, type, filterMode, path); - } finally { - delete window.__pendingBrowseFilter; - } + Promise.resolve( + typeof window.initializeBrowseView === 'function' + ? window.initializeBrowseView({ skipLoad: true }) + : null + ).then(() => window.applyBrowseFilter(category, type, filterMode, path)) + .catch((error) => { + console.warn('[App] 应用待处理题库筛选失败:', error); + }) + .finally(() => { + delete window.__pendingBrowseFilter; + }); } else if (typeof window.initializeBrowseView === 'function') { window.initializeBrowseView(); } @@ -2525,6 +2337,9 @@ class ExamSystemApp { .then(() => (typeof window.ensureBrowseGroup === 'function' ? window.ensureBrowseGroup() : null)) .then(() => (typeof window.ensurePracticeSuiteReady === 'function' ? window.ensurePracticeSuiteReady() : null)) .then(() => { + if (typeof window.ensurePracticeRecordsSync === 'function') { + return window.ensurePracticeRecordsSync('practice-view'); + } if (typeof window.syncPracticeRecords === 'function') { return window.syncPracticeRecords(); } @@ -2554,6 +2369,7 @@ class ExamSystemApp { } }, browseCategory(category, type = null, filterMode = null, path = null) { + const wasAlreadyInBrowse = this.currentView === 'browse'; try { window.__pendingBrowseFilter = { category, type, filterMode, path }; const descriptor = Object.getOwnPropertyDescriptor(window, '__browseFilter'); @@ -2568,14 +2384,16 @@ class ExamSystemApp { } catch (_) {} this.navigateToView('browse'); try { - if (typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) { + // 非 browse → browse 时,onViewActivated 已经消费 pending filter; + // 只有原本就在 browse 页时才需要补一次应用,避免双重加载。 + if (wasAlreadyInBrowse && typeof window.applyBrowseFilter === 'function' && document.getElementById('browse-view')?.classList.contains('active')) { window.applyBrowseFilter(category, type, filterMode, path); delete window.__pendingBrowseFilter; } } catch (_) {} }, async startCategoryPractice(category) { - const examIndex = await storage.get('exam_index', []); + const examIndex = await window.resolveActiveLibraryIndex(); const categoryExams = examIndex.filter((exam) => exam.category === category); if (categoryExams.length === 0) { window.showMessage(`${category} 分类暂无可用题目`, 'warning'); @@ -2599,8 +2417,6 @@ class ExamSystemApp { this.checkDependencies(); this.updateLoadingMessage('正在初始化状态管理...'); this.initializeGlobalCompatibility(); - this.updateLoadingMessage('正在加载持久化状态...'); - await this.loadPersistedState(); this.updateLoadingMessage('正在初始化响应式功能...'); this.initializeResponsiveFeatures(); this.updateLoadingMessage('正在加载系统组件...'); @@ -2801,20 +2617,13 @@ class ExamSystemApp { }, async loadInitialData() { try { - const examIndex = await storage.get('exam_index', []); - if (Array.isArray(examIndex)) { - this.setState('exam.index', examIndex); - } - const practiceRecords = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function' - ? await window.PracticeRecordAPI.list() - : []; - if (Array.isArray(practiceRecords)) { - this.setState('practice.records', practiceRecords); - } - const browseFilter = await storage.get('browse_filter', { category: 'all', type: 'all' }); + const browsePreference = await window.AppData.preferences.getBrowse(); + const browseFilter = browsePreference && browsePreference.filter + ? browsePreference.filter + : { category: 'all', type: 'all' }; this.setState('ui.browseFilter', browseFilter); await this.loadUserStats(); - this.updateOverviewStats(); + await this.updateOverviewStats(); } catch (error) { console.error('Failed to load initial data:', error); } @@ -2830,15 +2639,15 @@ class ExamSystemApp { lastPracticeDate: null, achievements: [] }; - const stats = window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function' - ? await window.PracticeRecordAPI.readStats({ fallback }) - : fallback; + const stats = Object.assign({}, fallback, await window.AppData.practice.getStats()); this.userStats = stats; return stats; }, async updateOverviewStats() { - const examIndex = this.getState('exam.index') || []; - const practiceRecords = this.getState('practice.records') || []; + const [examIndex, practiceRecords] = await Promise.all([ + window.resolveActiveLibraryIndex(), + window.AppData.practice.list({ projection: 'light' }) + ]); if (!Array.isArray(examIndex) || !Array.isArray(practiceRecords)) { console.warn('[App] 状态管理中的数据格式异常'); return; @@ -2876,7 +2685,7 @@ class ExamSystemApp { }, updateCategoryStats(examIndex, practiceRecords) { const categories = ['P1', 'P2', 'P3']; - const list = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []); + const list = Array.isArray(examIndex) ? examIndex : []; categories.forEach((category) => { const categoryExams = list.filter((exam) => exam.category === category); const categoryRecords = practiceRecords.filter((record) => { @@ -2964,7 +2773,12 @@ class ExamSystemApp { }, onStartEndless() { if (window.AppActions && typeof window.AppActions.startEndlessPractice === 'function') { - window.AppActions.startEndlessPractice(); + Promise.resolve(window.AppActions.startEndlessPractice()).catch((error) => { + console.error('[App] 无尽模式启动失败:', error); + if (typeof window.showMessage === 'function') { + window.showMessage('无尽模式启动失败,请稍后重试', 'error'); + } + }); return; } if (typeof window.showMessage === 'function') { @@ -3000,12 +2814,6 @@ class ExamSystemApp { } }, destroy() { - this.persistMultipleState({ - 'exam.index': 'exam_index', - 'ui.browseFilter': 'browse_filter', - 'exam.currentCategory': 'current_category', - 'exam.currentExamType': 'current_exam_type' - }); window.removeEventListener('resize', this.handleResize); if (this.sessionMonitorInterval) { clearInterval(this.sessionMonitorInterval); @@ -3152,7 +2960,7 @@ window.addEventListener('beforeunload', () => { * 兼容 file:// 协议 * * 数据层约定(0.6.2-fix 之后): - * - 示例记录必须经 PracticeRecordAPI.saveRecord,且具备 canonical examId + * - 示例记录必须经 AppData.practice.completeAttempt,且具备 canonical examId * - 回放依赖 realData.answers(object map)+ correctAnswerMap * - 引导状态键使用 exam_system_ 前缀,并兼容迁移旧键 */ @@ -3187,19 +2995,6 @@ window.addEventListener('beforeunload', () => { type: 'reading' }); - // 存储键名(带前缀;读取时兼容旧键) - const STORAGE_KEYS = { - COMPLETED: 'exam_system_onboarding_completed', - CURRENT_STEP: 'exam_system_onboarding_step', - LAST_SHOWN: 'exam_system_onboarding_last_shown' - }; - - const LEGACY_STORAGE_KEYS = { - COMPLETED: 'onboardingCompleted', - CURRENT_STEP: 'onboardingStep', - LAST_SHOWN: 'onboardingLastShown' - }; - const HISTORY_ITEM_SELECTOR = `#history-list .history-item.history-record-item[data-record-id="${DEMO_RECORD_ID}"]`; const HISTORY_TITLE_SELECTOR = @@ -3391,16 +3186,6 @@ window.addEventListener('beforeunload', () => { nextText: '下一步', lockScroll: true, disableHighlightPointer: true - }, - { - id: 'local-backup', - target: '#external-backup-entry-btn', - title: '💾 本地磁盘备份', - content: '若浏览器支持,可绑定本地文件夹做磁盘备份,与导出 JSON 互为补充。', - position: 'top', - nextText: '下一步', - lockScroll: true, - disableHighlightPointer: true } ] }, @@ -3450,71 +3235,51 @@ window.addEventListener('beforeunload', () => { // 状态管理器 class TourStateManager { constructor() { - this._storage = this._getStorage(); - this._migrateLegacyKeys(); + this._state = { completed: false, currentStep: 0, lastShown: null }; + this.ready = this._load(); } - _getStorage() { - try { - localStorage.setItem('__test__', '1'); - localStorage.removeItem('__test__'); - return localStorage; - } catch (e) { - const mem = {}; - return { - getItem: (k) => (Object.prototype.hasOwnProperty.call(mem, k) ? mem[k] : null), - setItem: (k, v) => { mem[k] = String(v); }, - removeItem: (k) => { delete mem[k]; } - }; - } + async _load() { + if (!global.AppData || !global.AppData.preferences) return; + await global.AppData.ready; + const stored = await global.AppData.preferences.getOnboarding(); + this._state = { + completed: stored.completed === true || stored.completed === 'true', + currentStep: Number.isFinite(Number(stored.currentStep)) ? Number(stored.currentStep) : 0, + lastShown: stored.lastShown || null + }; } - _migrateLegacyKeys() { - Object.keys(STORAGE_KEYS).forEach((name) => { - const nextKey = STORAGE_KEYS[name]; - const legacyKey = LEGACY_STORAGE_KEYS[name]; - if (!legacyKey) return; - try { - const current = this._storage.getItem(nextKey); - if (current !== null && current !== undefined && current !== '') return; - const legacy = this._storage.getItem(legacyKey); - if (legacy === null || legacy === undefined || legacy === '') return; - this._storage.setItem(nextKey, legacy); - this._storage.removeItem(legacyKey); - } catch (_) { - // ignore migration failures - } + _persist() { + if (!global.AppData || !global.AppData.preferences) return; + global.AppData.preferences.setOnboarding(this._state).catch((error) => { + console.warn('[Onboarding] 保存引导状态失败:', error); }); } isCompleted() { - return this._storage.getItem(STORAGE_KEYS.COMPLETED) === 'true'; + return this._state.completed === true; } getCurrentStep() { - const step = this._storage.getItem(STORAGE_KEYS.CURRENT_STEP); - return step ? parseInt(step, 10) : 0; + return this._state.currentStep || 0; } setStep(step) { - this._storage.setItem(STORAGE_KEYS.CURRENT_STEP, String(step)); - this._storage.setItem(STORAGE_KEYS.LAST_SHOWN, String(Date.now())); + this._state.currentStep = Number(step) || 0; + this._state.lastShown = Date.now(); + this._persist(); } markCompleted() { - this._storage.setItem(STORAGE_KEYS.COMPLETED, 'true'); - this._storage.removeItem(STORAGE_KEYS.CURRENT_STEP); + this._state.completed = true; + this._state.currentStep = 0; + this._persist(); } reset() { - this._storage.removeItem(STORAGE_KEYS.COMPLETED); - this._storage.removeItem(STORAGE_KEYS.CURRENT_STEP); - this._storage.removeItem(STORAGE_KEYS.LAST_SHOWN); - try { - this._storage.removeItem(LEGACY_STORAGE_KEYS.COMPLETED); - this._storage.removeItem(LEGACY_STORAGE_KEYS.CURRENT_STEP); - this._storage.removeItem(LEGACY_STORAGE_KEYS.LAST_SHOWN); - } catch (_) {} + this._state = { completed: false, currentStep: 0, lastShown: null }; + this._persist(); } } @@ -3735,8 +3500,9 @@ window.addEventListener('beforeunload', () => { destroy() { this.clearHighlight(); if (this._overlay) { - this._overlay.classList.remove('is-active'); - setTimeout(() => this._overlay?.remove(), 300); + const overlay = this._overlay; + overlay.classList.remove('is-active'); + setTimeout(() => overlay.remove(), 300); this._overlay = null; } if (this._holeEl) { @@ -3744,8 +3510,9 @@ window.addEventListener('beforeunload', () => { this._holeEl = null; } if (this._tooltip) { - this._tooltip.classList.remove('is-visible'); - setTimeout(() => this._tooltip?.remove(), 300); + const tooltip = this._tooltip; + tooltip.classList.remove('is-visible'); + setTimeout(() => tooltip.remove(), 300); this._tooltip = null; } } @@ -3763,7 +3530,11 @@ window.addEventListener('beforeunload', () => { this._boundKeyHandler = null; this._currentSubStep = 0; this._inSubSteps = false; - this._demoInjectPromise = null; + this._demoInjectTask = null; + this._demoCleanupPromise = null; + this._lifecycleToken = 0; + this._startTimer = null; + this._selectorWaiters = new Set(); this._lastDemoInjectResult = null; this._clickWaitCleanup = null; this._scrollBlocked = false; @@ -3773,12 +3544,14 @@ window.addEventListener('beforeunload', () => { this._savedScrollTop = 0; } - init() { + async init() { + await this._stateManager.ready; if (this._stateManager.isCompleted()) { return; } - setTimeout(() => { + this._startTimer = setTimeout(() => { + this._startTimer = null; this.start(); }, 1500); } @@ -3789,6 +3562,7 @@ window.addEventListener('beforeunload', () => { // 每次启动使用步骤副本,避免限级回放补丁污染默认配置 this._steps = cloneSteps(this._baseSteps); this._currentStep = fromBeginning ? 0 : this._stateManager.getCurrentStep(); + this._lifecycleToken += 1; this._isActive = true; this._inSubSteps = false; this._currentSubStep = 0; @@ -3812,6 +3586,14 @@ window.addEventListener('beforeunload', () => { stop() { this._isActive = false; + this._lifecycleToken += 1; + if (this._startTimer !== null) { + clearTimeout(this._startTimer); + this._startTimer = null; + } + this._cancelSelectorWaits(); + this._clearDemoRecordPreview(); + void this._cleanupDemoRecord(); this._clearClickWait(); this._unlockScroll(); this._unlockPointer(); @@ -4114,8 +3896,10 @@ window.addEventListener('beforeunload', () => { }; if (subStep.action === 'injectDemoRecord') { + const lifecycleToken = this._lifecycleToken; Promise.resolve(this._injectDemoRecord()) .then((result) => { + if (!this._isDemoLifecycleCurrent(lifecycleToken)) return; this._lastDemoInjectResult = result; if (!result || !result.ok) { this._showInjectFailureSubStep(parentStep, result); @@ -4128,6 +3912,7 @@ window.addEventListener('beforeunload', () => { proceed(); }) .catch((err) => { + if (!this._isDemoLifecycleCurrent(lifecycleToken)) return; console.error('[Onboarding] 注入示例记录失败:', err); this._lastDemoInjectResult = { ok: false, reason: 'exception', error: err }; this._showInjectFailureSubStep(parentStep, this._lastDemoInjectResult); @@ -4315,28 +4100,8 @@ window.addEventListener('beforeunload', () => { let list = []; try { - if (typeof global.getExamIndexState === 'function') { - list = global.getExamIndexState(); - } else if (Array.isArray(global.examIndex)) { - list = global.examIndex; - } - } catch (_) {} - - if (!Array.isArray(list) || list.length === 0) { - try { - const storage = global.persistentStore || global.storage; - if (storage && typeof storage.get === 'function') { - let activeKey = 'exam_index'; - try { - activeKey = await storage.get('active_exam_index_key', 'exam_index') || 'exam_index'; - } catch (_) {} - list = await storage.get(activeKey, []) || []; - if ((!Array.isArray(list) || list.length === 0) && activeKey !== 'exam_index') { - list = await storage.get('exam_index', []) || []; - } - } - } catch (_) {} - } + list = await global.resolveActiveLibraryIndex(); + } catch (_) { } if (!Array.isArray(list)) list = []; @@ -4505,44 +4270,82 @@ window.addEventListener('beforeunload', () => { })); } - _waitForSelector(selector, maxWait = 4000) { + _waitForSelector(selector, maxWait = 4000, lifecycleToken = this._lifecycleToken) { return new Promise((resolve) => { const startTime = Date.now(); + const waiter = { timer: null, settle: null }; + const settle = (value) => { + if (!this._selectorWaiters.has(waiter)) return; + if (waiter.timer !== null) clearTimeout(waiter.timer); + this._selectorWaiters.delete(waiter); + resolve(value); + }; const check = () => { + waiter.timer = null; + if (!this._isDemoLifecycleCurrent(lifecycleToken)) { + settle(null); + return; + } const el = document.querySelector(selector); if (el) { - resolve(el); + settle(el); return; } if (Date.now() - startTime > maxWait) { - resolve(null); + settle(null); return; } - setTimeout(check, 120); + waiter.timer = setTimeout(check, 120); }; + waiter.settle = settle; + this._selectorWaiters.add(waiter); check(); }); } + _cancelSelectorWaits() { + for (const waiter of Array.from(this._selectorWaiters)) { + waiter.settle(null); + } + } + + _isDemoLifecycleCurrent(token) { + return this._isActive && token === this._lifecycleToken; + } + async _injectDemoRecord() { - if (this._demoInjectPromise) { - return this._demoInjectPromise; + const lifecycleToken = this._lifecycleToken; + if (this._demoInjectTask && this._demoInjectTask.token === lifecycleToken) { + return this._demoInjectTask.promise; } - this._demoInjectPromise = (async () => { - const api = global.PracticeRecordAPI; - if (!api || typeof api.saveRecord !== 'function') { - return { ok: false, reason: 'PracticeRecordAPI unavailable' }; + const injectPromise = (async () => { + const api = global.AppData && global.AppData.practice; + if (!api || typeof api.completeAttempt !== 'function') { + return { ok: false, reason: 'AppData.practice unavailable' }; } const examContext = await this._resolveDemoExamContext(); + if (!this._isDemoLifecycleCurrent(lifecycleToken)) { + return { ok: false, reason: 'cancelled' }; + } + if (this._demoCleanupPromise) await this._demoCleanupPromise; + if (!this._isDemoLifecycleCurrent(lifecycleToken)) { + return { ok: false, reason: 'cancelled' }; + } const demoRecordObj = this._buildDemoRecord(examContext); + // 演示记录带 metadata.source = 'onboarding-demo',会被统一的来源判定 + // (js/data/practiceRecordSource.js)排除在练习记录列表、成绩统计与成就之外。 + // 引导需要用户看见这一行,所以显式为这一个 id 申请"视图层预览"许可: + // 只放行渲染,投影器读不到该白名单,统计与成就仍然不会被演示数据污染。 + this._allowDemoRecordPreview(); + try { - // 避免污染 user_stats - await api.saveRecord(demoRecordObj, { updateStats: false }); + await api.completeAttempt({ record: demoRecordObj }); } catch (err) { console.error('[Onboarding] 注入示例记录失败:', err); + this._clearDemoRecordPreview(); return { ok: false, reason: err && err.message ? err.message : 'saveRecord failed', @@ -4550,8 +4353,24 @@ window.addEventListener('beforeunload', () => { }; } + if (!this._isDemoLifecycleCurrent(lifecycleToken)) { + if (this._demoCleanupPromise) await this._demoCleanupPromise; + await this._cleanupDemoRecord({ refresh: false }); + return { ok: false, reason: 'cancelled' }; + } + await this._refreshPracticeHistory(); - const row = await this._waitForSelector(HISTORY_ITEM_SELECTOR, 5000); + if (!this._isDemoLifecycleCurrent(lifecycleToken)) { + if (this._demoCleanupPromise) await this._demoCleanupPromise; + await this._cleanupDemoRecord({ refresh: false }); + return { ok: false, reason: 'cancelled' }; + } + const row = await this._waitForSelector(HISTORY_ITEM_SELECTOR, 5000, lifecycleToken); + if (!this._isDemoLifecycleCurrent(lifecycleToken)) { + if (this._demoCleanupPromise) await this._demoCleanupPromise; + await this._cleanupDemoRecord({ refresh: false }); + return { ok: false, reason: 'cancelled' }; + } if (!row) { return { ok: false, @@ -4570,33 +4389,70 @@ window.addEventListener('beforeunload', () => { recordId: DEMO_RECORD_ID }; })(); + this._demoInjectTask = { token: lifecycleToken, promise: injectPromise }; try { - return await this._demoInjectPromise; + return await injectPromise; } finally { - this._demoInjectPromise = null; + if (this._demoInjectTask && this._demoInjectTask.promise === injectPromise) { + this._demoInjectTask = null; + } + } + } + + /** + * 申请/撤销演示记录的"视图层预览"许可。 + * 见 js/data/practiceRecordSource.js 的引导预览白名单说明:许可只影响练习记录列表渲染, + * practice.stats 与 achievements.progress 投影器永远按"演示数据"排除这条记录。 + */ + _allowDemoRecordPreview() { + const classifier = global.PracticeRecordSource; + if (classifier && typeof classifier.allowPreviewRecordId === 'function') { + classifier.allowPreviewRecordId(DEMO_RECORD_ID); } } - async _cleanupDemoRecord() { - const api = global.PracticeRecordAPI; - if (!api || typeof api.deleteById !== 'function') { + _clearDemoRecordPreview() { + const classifier = global.PracticeRecordSource; + if (classifier && typeof classifier.clearPreviewRecordId === 'function') { + classifier.clearPreviewRecordId(DEMO_RECORD_ID); + } + } + + async _cleanupDemoRecord(options = {}) { + // 先撤销预览许可再删除并重渲染:即使删除失败,这条演示记录也不会继续留在列表里。 + this._clearDemoRecordPreview(); + + if (this._demoCleanupPromise) return this._demoCleanupPromise; + + const api = global.AppData && global.AppData.practice; + if (!api || typeof api.delete !== 'function') { return; } - try { - await api.deleteById(DEMO_RECORD_ID, { updateStats: false }); - if (typeof global.syncPracticeRecords === 'function') { - await Promise.resolve(global.syncPracticeRecords({ forceRender: true })); - } else if (global.app && typeof global.app.renderPracticeHistory === 'function') { - await Promise.resolve(global.app.renderPracticeHistory()); - } else { - global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', { - detail: { source: 'onboarding-cleanup' } - })); + const refresh = options.refresh !== false; + const cleanupPromise = (async () => { + try { + await api.delete({ recordId: DEMO_RECORD_ID }); + if (!refresh) return; + if (typeof global.syncPracticeRecords === 'function') { + await Promise.resolve(global.syncPracticeRecords({ forceRender: true })); + } else if (global.app && typeof global.app.renderPracticeHistory === 'function') { + await Promise.resolve(global.app.renderPracticeHistory()); + } else { + global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', { + detail: { source: 'onboarding-cleanup' } + })); + } + } catch (err) { + console.warn('[Onboarding] 清理示例记录失败:', err); } - } catch (err) { - console.warn('[Onboarding] 清理示例记录失败:', err); + })(); + this._demoCleanupPromise = cleanupPromise; + try { + await cleanupPromise; + } finally { + if (this._demoCleanupPromise === cleanupPromise) this._demoCleanupPromise = null; } } @@ -4726,7 +4582,6 @@ window.addEventListener('beforeunload', () => { } _complete() { - this._cleanupDemoRecord(); this._stateManager.markCompleted(); this.stop(); } @@ -4766,7 +4621,6 @@ window.addEventListener('beforeunload', () => { if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { global.AppLazyLoader.markProvided([ "js/boot-fallbacks.js", - "js/patches/runtime-fixes.js", "js/app.js", "js/components/onboardingTour.js" ]); diff --git a/js/bundles/listening-record-bridge.bundle.js b/js/bundles/listening-record-bridge.bundle.js index 88af9408..af7fcc83 100644 --- a/js/bundles/listening-record-bridge.bundle.js +++ b/js/bundles/listening-record-bridge.bundle.js @@ -1,5 +1,3990 @@ /* Generated by scripts/build-bundles.mjs. Do not edit by hand. */ +/* ===== js/data/practiceRecordSource.js ===== */ +/** + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 + */ +(function initPracticeRecordSource(global) { + 'use strict'; + + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; + } + + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); + + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); + + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); + + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); + + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); + } + + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); + } + + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; + } + + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); + } + + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; + + if (demoSourceSet.has(readMetadataSource(record))) return false; + + return true; + } + + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } + + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } + + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); + + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } + + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; + } + + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; + } + return previewRecordIds.delete(normalizeId(recordId)); + } + + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); + } + + /** + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 + */ + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); + + global.PracticeRecordSource = api; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataCatalog.js ===== */ +(function installDataCatalog(global) { + 'use strict'; + + const V2_SCHEMA_VERSION = 2; + + function clone(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + + function objectDefault() { return {}; } + function arrayDefault() { return []; } + function nullableDefault() { return null; } + function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; } + function normalizeObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {}; + } + function normalizeNullableString(value) { + return value === null || value === undefined || value === '' ? null : String(value); + } + function isArray(value) { return Array.isArray(value); } + function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } + function isNullableString(value) { return value === null || typeof value === 'string'; } + + const CATALOG_OWNERS = new Set([ + 'settings', 'library', 'recovery', 'backups', 'vocab', + 'preferences', 'goals', 'achievements', 'system', 'practice' + ]); + const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']); + const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']); + + function isNonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); + } + + function ownerFromKey(logicalKey) { + const dot = String(logicalKey || '').indexOf('.'); + return dot > 0 ? logicalKey.slice(0, dot) : ''; + } + + function freezeEntry(entry) { + const logicalKey = String(entry.logicalKey || ''); + const owner = ownerFromKey(logicalKey); + const next = Object.assign({}, entry, { + logicalKey, + owner, + schemaVersion: V2_SCHEMA_VERSION, + export: entry.export === true, + import: entry.import || 'ignore' + }); + return Object.freeze(next); + } + + // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations), + // not as document keys. import merge identity is resolved in AppData, not here. + const definitions = [ + { + logicalKey: 'settings.values', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.configurations', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'library.importedIndexes', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.activeConfigurationId', classification: 'authoritative', + defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString, + export: true, import: 'replace' + }, + { + logicalKey: 'recovery.activeSessions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.drafts', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.interrupted', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.windowSession', classification: 'session', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.entries', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'merge-by-id' + }, + { + logicalKey: 'backups.settings', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'backups.exportHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.importHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'vocab.words', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'vocab.userConfig', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'vocab.lists', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'preferences.values', classification: 'preference', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'goals.items', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'achievements.manual', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'achievements.progress', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'system.migrations', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'system.operationJournal', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + } + ].map(freezeEntry); + + function validateCatalog(entries) { + if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry'); + const logicalKeys = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('DataCatalog entry must be an object'); + } + if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) { + throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`); + } + logicalKeys.add(entry.logicalKey); + } + for (const entry of entries) { + if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) { + throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`); + } + if (!CATALOG_CLASSIFICATIONS.has(entry.classification) + || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION + || typeof entry.defaultValue !== 'function' + || typeof entry.normalize !== 'function' + || typeof entry.validate !== 'function' + || typeof entry.export !== 'boolean' + || !IMPORT_POLICIES.has(entry.import)) { + throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`); + } + try { + const defaultValue = entry.defaultValue(); + if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) { + throw new Error('invalid default'); + } + } catch (_) { + throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`); + } + } + return true; + } + + validateCatalog(definitions); + const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry])); + const DataCatalog = Object.freeze({ + version: V2_SCHEMA_VERSION, + list() { return definitions.slice(); }, + get(logicalKey) { + const entry = byKey.get(String(logicalKey || '')); + if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`); + return entry; + }, + has(logicalKey) { return byKey.has(String(logicalKey || '')); }, + validate: validateCatalog, + clone + }); + + Object.defineProperty(global, '__AppDataV2Catalog', { + value: DataCatalog, + enumerable: false, + configurable: true, + writable: false + }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataKernel.js ===== */ +(function installDataKernel(global) { + 'use strict'; + + if (global.AppData) return; + + const catalog = global.__AppDataV2Catalog; + if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel'); + + const DATABASE_NAME = 'IELTSAtlasDataV2'; + // Version 2 uses a new schema, but initialization must still import the durable + // ExamSystemDB data owned by releases which predate AppData v2. + const DATABASE_VERSION = 2; + const DOCUMENT_STORE = 'documents'; + const SYSTEM_STORE = 'system'; + const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES)); + const OPERATION_JOURNAL_WINDOW = 500; + const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`; + const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000; + const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000; + const MAX_TIMER_DELAY_MS = 2147483647; + const LEGACY_DATABASE_NAME = 'ExamSystemDB'; + const LEGACY_STORE_NAME = 'keyValueStore'; + const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup'; + const LEGACY_EXTERNAL_STORE_NAME = 'handles'; + const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory'; + const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json'; + const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([ + 'practice_records', + 'vocab_user_config', + 'user_achievements' + ]); + + function clone(value) { return catalog.clone(value); } + function nowIso() { return new Date().toISOString(); } + function randomId(prefix) { + const random = global.crypto && typeof global.crypto.randomUUID === 'function' + ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `${prefix || 'op'}_${random}`; + } + + class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'AppDataError'; + this.code = code; + this.committed = false; + this.details = details; + } + } + function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); } + function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); } + + function normalizeTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback; + } + function scheduleTimeout(handler, delayMs) { + if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable'); + const handle = global.setTimeout.call(global, handler, delayMs); + if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle'); + return handle; + } + function cancelTimeout(handle) { + if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') { + try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ } + } + } + function withDeadline(handle, timeoutMs, description, resolve, reject) { + let settled = false; + let timer = null; + const settle = (callback, value) => { + if (settled) return; + settled = true; + cancelTimeout(timer); + callback(value); + }; + const expire = (error) => { + if (settled) return; + settled = true; + try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ } + reject(error); + }; + try { + timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, { + operation: description, timeoutMs, reason: 'timeout' + })), timeoutMs); + } catch (error) { + expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, { + operation: description, reason: 'watchdog-unavailable', cause: error && error.message + })); + } + return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } }; + } + + function canonicalizeJson(value, path = '$', ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path }); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') { + throw validation(`Non-JSON value at ${path}`, { path, type: typeof value }); + } + if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path }); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path }); + if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw validation(`Symbol-keyed property at ${path}`, { path }); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item, index) => { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path }); + return canonicalizeJson(item, `${path}[${index}]`, ancestors); + }); + } + const result = {}; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path }); + result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors); + } + return result; + } finally { ancestors.delete(value); } + } + function stableStringifyCanonical(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`; + } + function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); } + function checksum(value) { + const input = stableStringify(value); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + function legacyTimestamp(value) { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity; + if (Number.isFinite(Number(value))) return Number(value); + const parsed = Date.parse(value == null ? '' : String(value)); + return Number.isFinite(parsed) ? parsed : -Infinity; + } + function parseLegacyCandidate(value, outerTimestamp) { + let parsed = value; + let timestamp = legacyTimestamp(outerTimestamp); + const hasOuterTimestamp = timestamp !== -Infinity; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; } + } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') + && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) { + const innerTimestamp = legacyTimestamp(parsed.timestamp); + if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp; + parsed = parsed.data; + } else break; + } + return { value: clone(parsed), timestamp }; + } + function parseLegacyValue(value) { + const candidate = parseLegacyCandidate(value); + return candidate ? candidate.value : clone(value); + } + async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) { + const values = {}; + const candidates = {}; + let readComplete = true; + const consider = (alias, rawValue, timestamp, sourceRank) => { + const candidate = parseLegacyCandidate(rawValue, timestamp); + if (!candidate) return; + const previous = candidates[alias]; + if (!previous || candidate.timestamp > previous.timestamp + || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) { + candidates[alias] = Object.assign(candidate, { sourceRank }); + } + }; + if (indexedDBApi && typeof indexedDBApi.open === 'function') { + await new Promise((resolve) => { + let request; + let createdEmptyDatabase = false; + try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; } + request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); }; + request.onupgradeneeded = () => { + createdEmptyDatabase = true; + try { request.transaction.abort(); } catch (_) {} + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; } + const tx = db.transaction(LEGACY_STORE_NAME, 'readonly'); + const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys(); + const rows = tx.objectStore(LEGACY_STORE_NAME).getAll(); + tx.oncomplete = () => { + (keys.result || []).forEach((key, index) => { + const row = (rows.result || [])[index]; + // v1's keyValueStore persisted { key, value, timestamp } rows. + const validRow = row && typeof row === 'object' + && Object.prototype.hasOwnProperty.call(row, 'key') + && String(row.key) === String(key) + && Object.prototype.hasOwnProperty.call(row, 'value'); + if (!validRow) { + readComplete = false; + return; + } + consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0); + }); + db.close(); resolve(); + }; + tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); }; + }; + }); + } + for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) { + if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue; + for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) { + const key = fallbackStorage.key(index); + if (!key) continue; + const alias = key.startsWith('exam_system_') + ? key.slice('exam_system_'.length) + : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null); + if (!alias) continue; + try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ } + } + } + for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value; + Object.defineProperty(values, '__legacyReadComplete', { + value: readComplete, + enumerable: false, + configurable: false, + writable: false + }); + return values; + } + async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) { + if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null; + const directoryHandle = await new Promise((resolve) => { + let request; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value || null); + }; + try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; } + request.onerror = () => finish(null); + request.onupgradeneeded = () => { + try { request.transaction.abort(); } catch (_) {} + finish(null); + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) { + db.close(); finish(null); return; + } + const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly') + .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY); + get.onerror = () => { db.close(); finish(null); }; + get.onsuccess = () => { db.close(); finish(get.result); }; + }; + }); + if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null; + if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null; + const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false }); + const parsed = JSON.parse(await (await fileHandle.getFile()).text()); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined + ? parsed.data : parsed; + return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null; + } + function lookupEntry(logicalKey) { + if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey }); + return catalog.get(logicalKey); + } + function storeFor(logicalKey) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey }); + return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE; + } + function makeEnvelope(entry, data, options = {}) { + const state = options.state === 'cleared' ? 'cleared' : 'present'; + if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`); + let normalized = null; + if (state === 'present') { + try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) { + throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message }); + } + normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`); + if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey }); + } + const revision = options.revision === undefined ? 1 : Number(options.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`); + const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')), + updatedAt: options.updatedAt || nowIso(), state, data: normalized }; + payload.checksum = checksum(payload.data); + return Object.freeze(payload); + } + function validateEnvelope(entry, envelope) { + try { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) + || Number(envelope.schemaVersion) !== Number(entry.schemaVersion) + || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1 + || typeof envelope.operationId !== 'string' || !envelope.operationId + || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt + || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false; + const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`); + return (envelope.state !== 'cleared' || data === null) + && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data); + } catch (_) { return false; } + } + function operationId(value) { + if (value === undefined || value === null || value === '') return randomId('mutation'); + if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string'); + return value; + } + function expectedRevision(value, label) { + if (value === undefined || value === null) return null; + const revision = Number(value); + if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`); + return revision; + } + function compactJournal(journal) { + const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence)); + for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]]; + } + function readJournal(row) { + const envelope = row && row.envelope; + return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? clone(envelope.data) : {}; + } + function journalResult(journal, spec) { + const existing = journal[spec.operationId]; + if (!existing) return null; + if (existing.fingerprint !== spec.fingerprint || !existing.receipt) { + throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId }); + } + return clone(existing.receipt); + } + function writeJournal(journal, spec, receipt) { + const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1; + journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() }; + compactJournal(journal); + return journal; + } + function putJournal(tx, currentRow, journal, spec, receipt) { + const current = currentRow && currentRow.envelope; + const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), { + revision: current ? Number(current.revision) + 1 : 1, + operationId: spec.operationId, + normalized: true + }); + tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) }); + } + + class IndexedDBDriver { + constructor(indexedDBApi, options) { + this.indexedDB = indexedDBApi; + this.db = null; + this.mutationTimeoutMs = options.mutationTimeoutMs; + this.requestTimeoutMs = options.requestTimeoutMs; + } + async initialize() { + if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable'); + this.db = await new Promise((resolve, reject) => { + const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + let abandoned = false; + let settle; + request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion < 2) { + for (const name of ['authoritative', 'derived']) { + if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name); + } + } + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' }); + } + }; + settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject); + request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB')); + request.onblocked = () => { + abandoned = true; + settle.reject(new Error('IndexedDB upgrade blocked')); + }; + }); + this.db.onversionchange = () => this.close(); + return this; + } + close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} } + _open() { if (!this.db) throw new Error('IndexedDB connection closed'); } + _transaction(stores, mode, description, work, mutation = false) { + this._open(); + return new Promise((resolve, reject) => { + let failure = null; + let value; + let tx; + try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; } + const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject); + tx.oncomplete = () => settle.resolve(clone(value)); + tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); }; + tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`)); + const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} }; + try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); } + }); + } + readEnvelope(logicalKey) { + const store = storeFor(logicalKey); + return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(logicalKey); + request.onsuccess = () => done(request.result ? request.result.envelope : null); + request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`)); + }); + } + readEntity(store, recordId) { + return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(recordId); + request.onsuccess = () => done(request.result || null); + request.onerror = () => fail(request.error || new Error('Entity read failed')); + }); + } + readPracticeSnapshot(recordIds = null, options = {}) { + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES.slice(); + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean)); + return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => { + const result = Object.fromEntries(stores.map((store) => [store, []])); + let remaining = stores.length; + const finishStore = (store, rows) => { + result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || ''))); + remaining -= 1; + if (!remaining) done(result); + }; + for (const store of stores) { + const objectStore = tx.objectStore(store); + const request = requested && requested.size === 1 + ? objectStore.get(Array.from(requested)[0]) + : objectStore.getAll(); + request.onsuccess = () => { + const rows = requested && requested.size === 1 + ? (request.result ? [request.result] : []) + : request.result; + finishStore(store, rows); + }; + request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`)); + } + }); + } + listEntities(store) { + return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => { + const request = tx.objectStore(store).getAll(); + request.onsuccess = () => done(request.result || []); + request.onerror = () => fail(request.error || new Error('Entity list failed')); + }); + } + atomic(spec) { + return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => { + const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal'); + journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed')); + journalRequest.onsuccess = () => { + try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); } + }; + }, true); + } + exportSnapshot(envelopeKeys) { + return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => { + const result = { envelopes: {}, entities: {} }; + let remaining = STORE_NAMES.length; + for (const store of STORE_NAMES) { + const request = tx.objectStore(store).getAll(); + request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`)); + request.onsuccess = () => { + if (store === DOCUMENT_STORE || store === SYSTEM_STORE) { + for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope; + } else result.entities[store] = request.result || []; + remaining -= 1; + if (!remaining) done(result); + }; + } + }); + } + } + + function entityStore(store) { + const value = String(store || ''); + if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value }); + return value; + } + function validateEntityRow(store, row) { + if (!row || typeof row !== 'object' || Array.isArray(row) + || typeof row.recordId !== 'string' || !row.recordId + || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1 + || typeof row.operationId !== 'string' || !row.operationId + || typeof row.updatedAt !== 'string' || !row.updatedAt) { + throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null }); + } + const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`); + if (row.checksum !== checksum(data)) { + throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId }); + } + return row; + } + function normalizeEntityOperation(operation, index) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`); + const type = String(operation.type || ''); + const store = entityStore(operation.store); + if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`); + const recordId = type === 'clear' ? null : String(operation.recordId || ''); + if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`); + const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null; + return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) }; + } + function receiptFor(operationIdValue, revisions, warnings, pending) { + const receipt = { committed: true, revisions, operationId: operationIdValue, + derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() }; + const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]]; + return receipt; + } + + class DataKernel { + constructor(options = {}) { + this.driver = null; + this.backend = null; + this.state = 'created'; + this.failure = null; + this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB; + this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS); + this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS); + this.committedListeners = new Set(); + this.commitChannel = null; + this.instanceId = randomId('kernel'); + this.ready = null; + } + _initializeCommitChannel() { + if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return; + try { + const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME); + channel.onmessage = (message) => { + const data = message && message.data; + if (!data || data.sourceInstanceId === this.instanceId + || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return; + this._dispatchCommitted({ + operationId: data.operationId, + targets: clone(data.targets), + receipt: data.receipt ? clone(data.receipt) : null, + remote: true + }); + }; + this.commitChannel = channel; + } catch (_) { + this.commitChannel = null; + } + } + _closeCommitChannel() { + const channel = this.commitChannel; + this.commitChannel = null; + try { if (channel) channel.close(); } catch (_) {} + } + initialize() { + if (this.ready) return this.ready; + this.state = 'initializing'; + this.ready = new IndexedDBDriver(this.indexedDB, { + mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs + }).initialize().then((driver) => { + this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this; + }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null; + throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); }); + return this.ready; + } + close() { + if (this.driver) this.driver.close(); + this.driver = null; this.backend = null; + this._closeCommitChannel(); + if (this.state !== 'failed') this.state = 'closed'; + } + _assertReady() { + if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message }); + if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized'); + } + _latch(error) { + if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message }); + this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel(); + return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message }); + } + onCommitted(listener) { + if (typeof listener !== 'function') throw validation('Committed listener must be a function'); + this.committedListeners.add(listener); return () => this.committedListeners.delete(listener); + } + _dispatchCommitted(event) { + if (!event || !this.committedListeners.size) return; + const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback); + schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} })); + } + _notifyCommitted(targets, receipt) { + if (!targets.length) return; + const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false }; + this._dispatchCommitted(event); + if (this.commitChannel) { + try { + this.commitChannel.postMessage({ + sourceInstanceId: this.instanceId, + operationId: event.operationId, + targets: event.targets, + receipt: event.receipt + }); + } catch (_) { /* cross-realm notification is best effort */ } + } + } + async getEnvelope(logicalKey) { + this._assertReady(); const entry = lookupEntry(logicalKey); + try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async read(logicalKey, options = {}) { + const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey); + const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data; + return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data); + } + _documentSpec(changes, options) { + if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes'); + const opId = operationId(options.operationId); const seen = new Set(); + const prepared = changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`); + const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey); + if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel'); + if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey); + const state = change.state === 'cleared' ? 'cleared' : 'present'; + if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`); + if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`); + let data = null; + if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); } + return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) }; + }); + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings }); + return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) }; + } + async mutate(changes, options = {}) { + this._assertReady(); const spec = this._documentSpec(changes, options); + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) })); + let remaining = reads.length; + const finish = () => { + const revisions = {}; + for (const item of reads) { + const current = item.request.result ? item.request.result.envelope : null; + if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey }); + const revision = current ? Number(current.revision) : 0; + if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision }); + const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true }); + tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision; + } + const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []); + putJournal(tx, journalRow, journal, spec, receipt); + done(receipt); + }; + if (!remaining) { finish(); return; } + for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification })); + this._notifyCommitted(targets, receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); } + async readEntity(store, recordId, options = {}) { + this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId'); + try { + const row = await this.driver.readEntity(store, id); + if (!row) return null; + validateEntityRow(store, row); + return options.withMeta ? clone(row) : clone(row.data); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async readPracticeSnapshot(recordIds = null, options = {}) { + this._assertReady(); + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + try { + const snapshot = await this.driver.readPracticeSnapshot(ids, options); + const result = {}; + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES; + for (const store of stores) { + const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : []) + .filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + result[store] = options.withMeta + ? clone(validRows) + : validRows.map((row) => clone(row.data)); + } + return result; + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async listEntities(store, options = {}) { + this._assertReady(); store = entityStore(store); + if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId'); + try { + const rows = await this.driver.listEntities(store); + const validRows = rows.filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data)); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async mutateEntities(operations, options = {}) { + this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations'); + const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set(); + for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); } + for (const store of ENTITY_STORES) { + const scoped = items.filter((item) => item.store === store); + if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) { + throw validation(`Entity clear cannot be combined with other operations for ${store}`); + } + } + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length; + const finish = () => { const revisions = {}; + for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0; + if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`); + const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; } + } + for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; } + const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); }; + if (!remaining) { try { finish(); } catch (error) { fail(error); } return; } + for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async exportSnapshot(options = {}) { + this._assertReady(); + try { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null; + const shouldExport = (logicalKey) => { + if (!catalog.has(logicalKey)) return false; + const entry = lookupEntry(logicalKey); + if (selected && !selected.has(logicalKey)) return false; + if (entry.export === true) return true; + return options.includeSystem === true && entry.classification === 'system'; + }; + const data = await this.driver.exportSnapshot(shouldExport); + // Full/partial snapshots must be dense for their declared catalog + // range. An absent physical row means the catalog default, not an + // instruction that future importers should guess about. + for (const entry of catalog.list()) { + if (!shouldExport(entry.logicalKey) + || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue; + data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, { + state: 'cleared', + operationId: 'snapshot-default' + }); + } + if (Array.isArray(options.entityStores)) { + const selectedStores = new Set(options.entityStores.map(entityStore)); + for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store]; + } + const payload = { envelopes: data.envelopes, entities: data.entities }; + return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) }; + } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async installSnapshot(snapshot, options = {}) { + this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} }; + const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities'); + if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid'); + if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch'); + const changes = []; + for (const [logicalKey, envelope] of Object.entries(envelopes)) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue; + if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`); + changes.push({ logicalKey, entry, envelope }); + } + const entityRows = {}; + for (const store of ENTITY_STORES) { + if (!Object.prototype.hasOwnProperty.call(entities, store)) continue; + const rows = entities[store]; + if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`); + const ids = new Set(); + entityRows[store] = rows.map((row) => { + if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`); + const recordId = String(row.recordId); + if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`); + ids.add(recordId); + const data = canonicalizeJson(row.data); + if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`); + const revision = row.revision === undefined ? 1 : Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`); + return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) }; + }); + } + if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data'); + const resetJournal = options.resetJournal === true; + const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object' + ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken') + : null; + const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {}; + const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {}; + const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({ + kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey) + })).concat(Object.entries(entityChecks).map(([store, expected]) => ({ + kind: 'entities', store, expected, request: tx.objectStore(store).getAll() + }))); + const finish = () => { + for (const read of reads) { + if (read.kind === 'document') { + const current = read.request.result ? read.request.result.envelope : null; + const actualRevision = current ? Number(current.revision) : 0; + const expectedRevision = Number(read.expected) || 0; + if (actualRevision !== expectedRevision) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision }); + } + } else { + const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0])); + const expected = read.expected && typeof read.expected === 'object' ? read.expected : {}; + const ids = new Set(Object.keys(actual).concat(Object.keys(expected))); + for (const recordId of ids) { + const current = actual[recordId] || 0; + const wanted = Number(expected[recordId]) || 0; + if (current !== wanted) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId }); + } + } + } + } + const revisions = {}; + for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); } + for (const [store, rows] of Object.entries(entityRows)) { + tx.objectStore(store).clear(); + for (const row of rows) tx.objectStore(store).put(row); + } + const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt); + }; + if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; } + let remaining = reads.length; + for (const read of reads) { + read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed')); + read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; + } + } })); + const targets = changes + .filter((item) => item.entry.owner !== 'backups') + .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification })) + .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' }))); + this._notifyCommitted(targets, receipt); + return receipt; + } + catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); } + } + + Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/appData.js ===== */ +(function installAppData(global) { + 'use strict'; + + const internals = global.__AppDataV2Internals; + if (!internals || typeof internals.DataKernel !== 'function') { + throw new Error('AppData v2 requires DataKernel'); + } + const { + DataKernel, + AppDataError, + catalog, + clone, + randomId, + nowIso, + checksum + } = internals; + const kernel = new DataKernel(); + const importPlans = new Map(); + const RECOVERY_KEYS = Object.freeze({ + activeSession: 'recovery.activeSessions', + draft: 'recovery.drafts', + interrupted: 'recovery.interrupted', + rejectedCompletion: 'recovery.rejectedCompletions' + }); + const PREFERENCE_FIELDS = Object.freeze({ + theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode', + resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay', + threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget', + consent: 'consent', logConfig: 'logConfig' + }); + const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + + function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function asArray(value) { return Array.isArray(value) ? value : []; } + function idOf(value, fields) { + for (const field of fields) { + if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]); + } + return ''; + } + function importedLibraryId(value, options = {}) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id && options.nullable) return null; + if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required'); + if (/^exam_index(?:_|$)/.test(id)) { + throw new AppDataError('VALIDATION', 'Unsupported library configuration id'); + } + return id; + } + function assertObject(value, message) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function assertArray(value, message) { + if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function jsonValue(value, label = 'value') { + try { + const serialized = JSON.stringify(value, (_key, current) => { + if (typeof current === 'bigint') return String(current); + if (typeof current === 'number' && !Number.isFinite(current)) return null; + return current; + }); + if (serialized === undefined) return null; + return JSON.parse(serialized); + } catch (error) { + throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message }); + } + } + function operationId(command, prefix, semanticPayload = command) { + const id = command && command.operationId ? String(command.operationId) : randomId(prefix); + jsonValue(semanticPayload, `${prefix} payload`); + return id; + } + function mutationOptions(command, prefix, semanticPayload, extra = {}) { + const source = asObject(command); + const payload = jsonValue(semanticPayload, `${prefix} payload`); + const intent = { command: prefix, payload }; + if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) { + intent.expectedRevision = source.expectedRevision; + } + return Object.assign({}, extra, { + operationId: operationId(source, prefix, payload), + intent + }); + } + function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) { + return mutationOptions(asObject(options), prefix, semanticPayload, extra); + } + function deterministicEntityId(prefix, operation) { + return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`; + } + function normalizeAccuracyRatio(value, label = 'accuracy') { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`); + } + return numeric > 1 ? numeric / 100 : numeric; + } + function defaultStats() { + return { + totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0, + reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + lastUpdated: nowIso() + }; + } + + function nonNegativeScalar(value) { + if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + } + + function firstNonNegativeScalar(...values) { + for (const value of values) { + const numeric = nonNegativeScalar(value); + if (numeric !== null) return numeric; + } + return null; + } + + function normalizeAnswerQuestionId(value, index = 0) { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || `q${index + 1}`; + } + + function normalizeAnswerValue(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); + if (value && typeof value === 'object') { + if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); + if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); + if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); + return jsonValue(value, 'practice answer'); + } + return typeof value === 'string' ? value : String(value); + } + + function normalizeAnswerMap(value) { + const normalized = {}; + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry === undefined || entry === null) return; + const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; + const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); + normalized[questionId] = normalizeAnswerValue( + hasOwn(item, 'answer') ? item.answer + : hasOwn(item, 'userAnswer') ? item.userAnswer + : hasOwn(item, 'value') ? item.value + : entry + ); + }); + return normalized; + } + if (!value || typeof value !== 'object') return normalized; + Object.entries(value).forEach(([questionId, answer], index) => { + if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; + normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); + }); + return normalized; + } + + function mergeAnswerMaps(...sources) { + const merged = {}; + for (const source of sources) { + const normalized = normalizeAnswerMap(source); + for (const [questionId, answer] of Object.entries(normalized)) { + if (!hasOwn(merged, questionId)) merged[questionId] = answer; + } + } + return merged; + } + + function canonicalizeAnswerSource(record) { + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const rawRealData = asObject(rawData.realData); + record.answers = mergeAnswerMaps( + record.answers, + record.answerMap, + record.answerList, + realData.answers, + realData.answerMap, + rawData.answers, + rawData.answerMap, + rawRealData.answers + ); + // These names remain accepted only at the compatibility boundary. The + // canonical detail layer owns one user-answer source: `answers`. + delete record.answerMap; + delete record.answerList; + } + + function normalizePracticeScoreFields(record) { + const scoreInfo = asObject(record.scoreInfo); + const realScoreInfo = asObject(asObject(record.realData).scoreInfo); + const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); + const overloadedCorrectAnswers = record.correctAnswers; + const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; + if (isAnswerMap) { + const existingMap = record.correctAnswerMap; + const overloadedObject = asObject(overloadedCorrectAnswers); + const existingObject = asObject(existingMap); + if (Object.keys(overloadedObject).length) { + record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); + } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { + record.correctAnswerMap = clone(overloadedCorrectAnswers); + } + } + + let correctAnswers = firstNonNegativeScalar( + overloadedCorrectAnswers, + record.correctAnswersCount, + record.correctCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct, + rawScoreInfo.correctAnswers, + rawScoreInfo.correct + ); + if (correctAnswers === null) { + const comparisons = asArray(record.answerComparison).length + ? asArray(record.answerComparison) + : asArray(asObject(record.realData).answerComparison); + if (comparisons.length) { + correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; + } + } + if (correctAnswers !== null) record.correctAnswers = correctAnswers; + else if (isAnswerMap) record.correctAnswers = 0; + + const totalQuestions = firstNonNegativeScalar( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total, + rawScoreInfo.totalQuestions, + rawScoreInfo.total + ); + if (totalQuestions !== null) record.totalQuestions = totalQuestions; + } + + function canonicalizeRecord(input) { + assertObject(input, 'practice record must be an object'); + const record = jsonValue(input, 'practice record'); + record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record'); + record.sessionId = idOf(record, ['sessionId']) || record.id; + record.timestamp = record.timestamp || record.completedAt || record.date || nowIso(); + record.completedAt = record.completedAt || record.timestamp; + record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice'; + record.metadata = asObject(record.metadata); + if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; + if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; + canonicalizeAnswerSource(record); + normalizePracticeScoreFields(record); + for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { + if (record[field] === undefined || record[field] === null || record[field] === '') continue; + const numeric = Number(record[field]); + if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`); + record[field] = numeric; + } + if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy'); + return jsonValue(record, 'canonical practice record'); + } + + function deriveQuestionTypeErrorCounts(source) { + const record = asObject(source); + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const counts = {}; + const addCount = (type, value) => { + const key = String(type || 'other').trim() || 'other'; + const amount = Math.max(0, Number(value) || 0); + if (amount > 0) counts[key] = (counts[key] || 0) + amount; + }; + const performanceSources = [ + record.questionTypePerformance, + realData.questionTypePerformance, + rawData.questionTypePerformance + ]; + let hasPerformanceData = false; + for (const performanceMap of performanceSources) { + if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; + let sourceHasPerformanceData = false; + for (const [type, value] of Object.entries(performanceMap)) { + const performance = asObject(value); + const total = Number(performance.total ?? performance.totalQuestions); + const correct = Number(performance.correct ?? performance.correctAnswers); + if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; + hasPerformanceData = true; + sourceHasPerformanceData = true; + addCount(type, total - correct); + } + if (sourceHasPerformanceData) break; + } + if (hasPerformanceData) return counts; + + const questionTypeMap = Object.assign( + {}, + asObject(rawData.questionTypeMap), + asObject(realData.questionTypeMap), + asObject(record.questionTypeMap) + ); + const normalizedTypeMap = {}; + for (const [questionId, type] of Object.entries(questionTypeMap)) { + normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; + } + const detailSources = [ + record.answerDetails, + asObject(record.scoreInfo).details, + realData.answerDetails, + asObject(realData.scoreInfo).details, + rawData.answerDetails, + asObject(rawData.scoreInfo).details + ]; + const seenQuestions = new Set(); + for (const details of detailSources) { + if (!details || typeof details !== 'object' || Array.isArray(details)) continue; + for (const [questionId, value] of Object.entries(details)) { + const detail = asObject(value); + const normalizedId = String(questionId).trim().toLowerCase(); + if (!normalizedId || seenQuestions.has(normalizedId)) continue; + let isWrong = detail.isCorrect === false || detail.correct === false; + if (detail.isCorrect === true || detail.correct === true) isWrong = false; + else if (!isWrong) { + const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); + const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); + isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); + } + if (!isWrong) continue; + seenQuestions.add(normalizedId); + addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); + } + } + return counts; + } + + function lightSuiteEntry(source, fallbackType = null) { + const entry = asObject(source); + const scoreInfo = asObject(entry.scoreInfo); + const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); + const metadata = asObject(entry.metadata); + const totalQuestions = firstNonNegativeScalar( + entry.totalQuestions, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total + ) ?? 0; + const correctAnswers = firstNonNegativeScalar( + entry.correctAnswers, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct + ) ?? 0; + const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'suite entry accuracy' + ) || 0; + const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0; + return jsonValue({ + id: entry.id || null, + sessionId: entry.sessionId || null, + examId: entry.examId || metadata.examId || null, + title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', + type: entry.type || metadata.type || metadata.examType || fallbackType || null, + date: entry.date || entry.completedAt || entry.timestamp || null, + duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage, + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + }, 'suite entry light projection'); + } + + function lightFromCanonical(source) { + const scoreInfo = asObject(source.scoreInfo); + const realScoreInfo = asObject(asObject(source.realData).scoreInfo); + const metadata = asObject(source.metadata); + const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const dataSource = hasOwn(source, 'dataSource') + ? source.dataSource + : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined); + const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0; + const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0; + const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'practice light accuracy' + ) || 0; + return jsonValue({ + id: source.id, + sessionId: source.sessionId, + examId: source.examId || source.metadata.examId || null, + title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '', + type: source.type, + mode: source.mode || source.practiceMode || null, + timestamp: source.timestamp, + completedAt: source.completedAt, + date: source.date || source.completedAt || source.timestamp || null, + startTime: source.startTime || null, + endTime: source.endTime || null, + duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, + score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` + // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 + // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 + dataSource, + // Summaries are list indexes. Keep only the metadata needed to filter, show a + // source label, or locate the originating library; details stay in their entity. + metadata: Object.fromEntries([ + // `source` must stay: PracticeRecordSource uses metadata.source demo markers + // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full. + 'examId', 'examTitle', 'title', 'type', 'category', 'frequency', + 'dataSource', 'source', 'libraryConfigurationId' + ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), + suite: source.suite == null ? null : clone(asObject(source.suite)), + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + }, 'practice light projection'); + } + + function projectLight(record) { + if (!record) return null; + return lightFromCanonical(canonicalizeRecord(record)); + } + + function firstNonEmpty(...values) { + let first; + for (const value of values) { + if (value === undefined || value === null) continue; + if (first === undefined) first = value; + if (Array.isArray(value) && value.length) return clone(value); + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value); + if (typeof value !== 'object') return clone(value); + } + return first === undefined ? {} : clone(first); + } + + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); + + function withoutRawData(value) { + if (Array.isArray(value)) return value.map(withoutRawData); + if (!value || typeof value !== 'object') return clone(value); + const clean = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item); + } + return clean; + } + + function splitPracticeRecord(input) { + const source = canonicalizeRecord(input); + const summary = lightFromCanonical(source); + const detail = { recordId: source.id }; + const annotations = { recordId: source.id }; + for (const [key, value] of Object.entries(source)) { + if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); + else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { + const next = Object.assign({}, asObject(entry)); + canonicalizeAnswerSource(next); + const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); + for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); + } + const annotation = {}; + for (const annotationKey of ANNOTATION_FIELDS) { + if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; } + if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey]; + if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey]; + } + delete next.realData; delete next.rawData; + if (Object.keys(annotation).length) { + if (!annotations.suiteEntries) annotations.suiteEntries = {}; + annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation; + } + return withoutRawData(next); + }); + else detail[key] = withoutRawData(value); + } + // Accept the old mirror only as an input normalization boundary; it is never persisted. + const realData = asObject(source.realData); const rawData = asObject(source.rawData); + for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); + } + for (const key of ANNOTATION_FIELDS) { + if (hasOwn(annotations, key)) continue; + if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]); + else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]); + } + return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') }; + } + + function joinPracticeRecord(summary, detail, annotations, projection = 'full') { + if (!summary) return null; + const mode = String(projection || 'full').toLowerCase(); + const light = clone(summary); + if (mode === 'light' || mode === 'summary') return light; + const joined = Object.assign({}, light, clone(asObject(detail))); + delete joined.recordId; + if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection'); + const annotationData = asObject(annotations); + for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value); + if (Array.isArray(joined.suiteEntries)) { + const suiteAnnotations = asObject(annotationData.suiteEntries); + joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {}))); + } + return jsonValue(joined, 'practice full projection'); + } + + function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); } + + // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。 + // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定, + // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败, + // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。 + const practiceRecordSource = global.PracticeRecordSource; + if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') { + throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)'); + } + const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord; + + function computeStats(records) { + const stats = defaultStats(); + for (const record of asArray(records).filter(isRealPracticeRecord)) { + const summary = projectLight(record); + const type = String(summary.type || '').toLowerCase(); + const target = type.includes('listen') ? stats.listening : stats.reading; + stats.totalPractices += 1; + stats.totalQuestions += summary.totalQuestions; + stats.correctAnswers += summary.correctAnswers; + target.practices += 1; + target.questions += summary.totalQuestions; + target.correct += summary.correctAnswers; + } + stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0; + for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0; + stats.lastUpdated = nowIso(); + return stats; + } + + function validIso(value) { + if (value === null || value === undefined || value === '') return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function practiceType(record) { + const metadata = asObject(record.metadata); + const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType, + record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase(); + if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening'; + if (hints.includes('read')) return 'reading'; + return null; + } + + function accuracyRatio(record) { + const summary = lightFromCanonical(record); + const value = Number(summary.accuracy); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } + + function durationSeconds(record) { + const scoreInfo = asObject(record.scoreInfo); + const realData = asObject(record.realData); + const realScoreInfo = asObject(realData.scoreInfo); + for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; + } + return 0; + } + + function earlierUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function laterUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function computeAchievementProgress(records, manual, existing) { + const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord) + .map((record, index) => ({ + record, + index, + unlockedAt: validIso(record.completedAt || record.timestamp), + time: new Date(record.completedAt || record.timestamp).getTime() + })) + .sort((left, right) => { + const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER; + const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER; + return leftTime - rightTime || left.index - right.index; + }); + const candidates = {}; + const setThreshold = (id, list, count) => { + if (list.length >= count) candidates[id] = list[count - 1].unlockedAt; + }; + setThreshold('first_step', items, 1); + setThreshold('practice_bronze', items, 10); + setThreshold('practice_silver', items, 50); + setThreshold('practice_gold', items, 100); + setThreshold('practice_platinum', items, 200); + + const reading = items.filter((item) => practiceType(item.record) === 'reading'); + const listening = items.filter((item) => practiceType(item.record) === 'listening'); + setThreshold('reading_first', reading, 1); + setThreshold('reading_bronze', reading, 10); + setThreshold('reading_silver', reading, 50); + setThreshold('reading_gold', reading, 100); + setThreshold('listening_first', listening, 1); + setThreshold('listening_bronze', listening, 10); + setThreshold('listening_silver', listening, 50); + setThreshold('listening_gold', listening, 100); + if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt); + if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt); + + let cumulativeDuration = 0; + let cumulativeAccuracy = 0; + let perfectCount = 0; + let speedCount = 0; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const accuracy = accuracyRatio(item.record); + const duration = durationSeconds(item.record); + cumulativeDuration += duration; + cumulativeAccuracy += accuracy; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt; + if (accuracy >= 1) { + perfectCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt; + if (perfectCount === 3) candidates.perfect_three = item.unlockedAt; + if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt; + } + if (duration > 0 && duration <= 300 && accuracy > 0.8) { + speedCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt; + if (speedCount === 3) candidates.speed_three = item.unlockedAt; + if (speedCount === 10) candidates.speed_ten = item.unlockedAt; + } + } + + const dayItems = new Map(); + for (const item of items) { + if (!item.unlockedAt) continue; + const day = item.unlockedAt.slice(0, 10); + if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt); + } + const days = Array.from(dayItems.keys()).sort(); + let streak = 0; + let previousDay = null; + for (const day of days) { + const currentDay = new Date(`${day}T00:00:00.000Z`).getTime(); + streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1; + previousDay = currentDay; + if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day); + if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day); + if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day); + if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day); + } + + const progress = {}; + const mergeUnlocked = (source) => { + for (const [rawId, value] of Object.entries(asObject(source))) { + if (!value || rawId === 'updatedAt') continue; + const id = rawId; + const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null; + if (!progress[id]) progress[id] = { unlockedAt }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + }; + mergeUnlocked(existing); + mergeUnlocked(manual); + for (const [id, unlockedAt] of Object.entries(candidates)) { + if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + return jsonValue(progress, 'achievement progress'); + } + + // Entity records are authoritative. Projections are assembled on reads, never cached or + // scheduled as follow-up work; this keeps a successful write immediately observable. + async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } + + async function retryMergeConflict(options, task, maxAttempts = 3) { + const explicitRevision = hasOwn(options, 'expectedRevision'); + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) { + throw error; + } + } + } + throw lastError; + } + + async function readCollectionMeta(logicalKey) { + const meta = await kernel.read(logicalKey, { withMeta: true }); + return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 }; + } + + function retainBackupEntries(items, limit = 20, preserveIds = []) { + const cap = Math.max(1, Number(limit) || 20); + const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || '')); + const entries = asArray(items).filter(Boolean).sort(newestFirst); + const retained = []; + const retainedIds = new Set(); + const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean)); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap) break; + if (retainedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + return retained; + } + + function hasOwn(value, key) { + return Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); + } + + function normalizeLibraryConfigurationId(value) { + return importedLibraryId(value, { nullable: true }); + } + + async function practiceRecordWithLibraryProvenance(source, command, options = {}) { + assertObject(source, 'practice record must be an object'); + const record = jsonValue(source, 'practice record'); + const metadata = asObject(record.metadata); + let configurationId; + + if (hasOwn(command, 'libraryConfigurationId')) { + configurationId = command.libraryConfigurationId; + } else if (hasOwn(metadata, 'libraryConfigurationId')) { + configurationId = metadata.libraryConfigurationId; + } else if (hasOwn(record, 'libraryConfigurationId')) { + configurationId = record.libraryConfigurationId; + } else { + configurationId = await kernel.read('library.activeConfigurationId'); + } + + const normalizedId = normalizeLibraryConfigurationId(configurationId); + record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId }); + + if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) { + record.suiteEntries = record.suiteEntries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const next = jsonValue(entry, 'practice suite entry'); + const entryMetadata = asObject(next.metadata); + const entryId = hasOwn(entryMetadata, 'libraryConfigurationId') + ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId) + : normalizedId; + next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId }); + return next; + }); + } + + return record; + } + + function practiceRecordMatches(record, identities) { + const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean)); + if (!expected.size || !record || typeof record !== 'object') return false; + return ['id', 'recordId', 'sessionId'].some((field) => { + const value = record[field]; + return value !== undefined && value !== null && expected.has(String(value)); + }); + } + + function practiceLayerId(row) { + return String(row && (row.recordId || row.id || row.sessionId) || ''); + } + async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { + // The real kernel reads all three entity stores in one readonly + // IndexedDB transaction. Keep the fallback for deliberately minimal + // embedders and unit-test kernels that only expose the original methods. + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); + } + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + const summaries = ids === null + ? await kernel.listEntities('practiceSummaries', { withMeta }) + : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); + const targetIds = summaries.map(practiceLayerId).filter(Boolean); + const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) + .then((rows) => rows.filter(Boolean)); + return { + practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], + practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], + practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] + }; + } + async function practiceLayers(recordId, withMeta = false) { + const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + return { + summary: find('practiceSummaries'), + detail: find('practiceDetails'), + annotations: find('practiceAnnotations') + }; + } + async function suiteChildRecordIds(command, aggregateRecordId) { + const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); + const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); + if (sessionIds.size) { + const summaries = await kernel.listEntities('practiceSummaries'); + for (const summary of summaries) { + if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); + } + } + ids.delete(String(aggregateRecordId)); + return ids; + } + function entityRevision(row) { return row ? Number(row.revision) : 0; } + function practiceUpserts(recordId, layers, existing = {}) { + return [ + { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) }, + { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) }, + { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) } + ]; + } + async function joinedPractice(recordId, projection, snapshot = null) { + const mode = String(projection || 'full').toLowerCase(); + const layers = snapshot || await practiceProjectionSnapshot([recordId], false, + mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : null)); + const summary = asArray(layers.practiceSummaries) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (!summary) return null; + if (mode === 'light' || mode === 'summary') return clone(summary); + const detail = asArray(layers.practiceDetails) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); + const annotations = asArray(layers.practiceAnnotations) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + return joinPracticeRecord(summary, detail, annotations, mode); + } + function practiceSummaryTime(summary) { + const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); + return Number.isFinite(time) ? time : 0; + } + function isReadingInsightSummary(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => practiceType(entry) === 'reading'); + } + return practiceType(asObject(summary)) === 'reading'; + } + function needsQuestionTypeInsightBackfill(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => + practiceType(entry) === 'reading' + && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); + } + return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + } + const practice = Object.freeze({ + async list(options = {}) { + await ready; + const projection = String(options.projection || 'full').toLowerCase(); + const snapshot = await practiceProjectionSnapshot(null, false, + projection === 'light' || projection === 'summary' + ? ['practiceSummaries'] + : null); + const summaries = asArray(snapshot.practiceSummaries); + if (projection === 'light' || projection === 'summary') return summaries; + return (await Promise.all(summaries + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) + .filter(Boolean); + }, + async listInsights(options = {}) { + await ready; + const requestedLimit = Number(options.limit); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 50) + : 10; + const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); + const summaries = asArray(snapshot.practiceSummaries) + .filter(isReadingInsightSummary) + .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) + .slice(0, limit); + return summaries.map((summary) => { + if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); + const detail = asArray(snapshot.practiceDetails) + .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; + return detail + ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) + : clone(summary); + }); + }, + async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, + async completeAttempt(command) { + await ready; + const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command; + const mutation = mutationOptions(command, 'practice-complete', source); + const recordInput = await practiceRecordWithLibraryProvenance(source, command); + if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId); + const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities( + practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation)); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async finalizeSuite(command) { + await ready; assertObject(command, 'finalizeSuite command is required'); + const mutation = mutationOptions(command, 'practice-suite', command); + const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); + if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); + const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command, async () => { + const existing = await practiceLayers(recordId, true); + const children = await suiteChildRecordIds(command, recordId); + const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); + return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); + }); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async updateAnnotations(command) { + await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || ''); + return retryMergeConflict(command, async () => { + const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`); + if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`); + const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data))); + const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default'); + if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) { + if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`); + annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) }); + } else { + if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`); + annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) }); + Object.assign(annotations, clone(asObject(command.patch))); + } + return kernel.mutateEntities([{ + type: 'upsert', + store: 'practiceAnnotations', + recordId, + data: annotations, + expectedRevision: entityRevision(current.annotations) + }], mutationOptions(command, 'practice-annotations', command)); + }); + }, + async delete(command) { + await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required'); + const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId })); + return Object.assign({}, receipt, { deletedCount: 1 }); + }, + async deleteMany(command) { + await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean))); + if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id]))); + if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); + }, + async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, + projectLight, + projectDetail + }); + + const settings = Object.freeze({ + async getAll() { await ready; return kernel.read('settings.values'); }, + async patch(values, options = {}) { + await ready; assertObject(values, 'settings.patch requires an object'); + const mutation = optionsMutationOptions(options, 'settings-patch', values); + return retryMergeConflict(options, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + }); + }, + async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); } + }); + + const library = Object.freeze({ + async listConfigurations() { await ready; return kernel.read('library.configurations'); }, + async getActive() { await ready; return kernel.read('library.activeConfigurationId'); }, + async getIndex(configurationId) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + if (id === null) return []; + const indexes = await kernel.read('library.importedIndexes'); + return asArray(indexes[id]); + }, + async updateConfiguration(configuration, options = {}) { + await ready; assertObject(configuration, 'library.updateConfiguration requires an object'); + const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId'])); + const current = await kernel.read('library.configurations', { withMeta: true }); + const configs = asArray(current.data); + const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id); + const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id }); + if (index >= 0) configs[index] = next; else configs.push(next); + return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration)); + }, + async activate(configurationId, options = {}) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + const current = await kernel.read('library.activeConfigurationId', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id })); + }, + async import(command) { + await ready; assertObject(command, 'library.import requires a command'); + const id = importedLibraryId(command.id || command.configurationId || randomId('library')); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id); + configs.push(Object.assign({}, asObject(command.configuration), { id, key: id })); + const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) }); + return kernel.mutate([ + { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ], mutationOptions(command, 'library-import', command)); + }, + async remove(configurationId, options = {}) { + await ready; const id = importedLibraryId(configurationId); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true }); + const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id]; + const changes = [ + { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ]; + if (String(activeMeta.data || '') === id) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 }); + } + return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id })); + }, + async resolveIndex() { + await ready; + const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]); + return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]); + } + }); + + function recoveryKey(kind) { + const key = RECOVERY_KEYS[String(kind || '')]; + if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`); + return key; + } + // Recovery document TTL is an AppData domain rule, not a catalog policy field. + const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000; + function recoveryTimestamp(item) { + for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) { + const parsed = Date.parse(item && item[field]); + if (Number.isFinite(parsed)) return parsed; + } + return null; + } + async function pruneRecoveryKey(logicalKey) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await kernel.read(logicalKey, { withMeta: true }); + const items = asArray(current.data); + const cutoff = Date.now() - RECOVERY_TTL_MS; + const retained = items.filter((item) => { + const timestamp = recoveryTimestamp(item); + return timestamp === null || timestamp > cutoff; + }); + if (retained.length === items.length) return items; + try { + await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], { + operationId: randomId('recovery-ttl') + }); + return retained; + } catch (error) { + if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error; + } + } + return kernel.read(logicalKey); + } + async function cleanupExpiredRecovery() { + for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey); + } + const windowSession = Object.freeze({ + save(name, value) { + if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable'); + const logicalName = String(name || 'default'); + const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) }; + global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload)); + return true; + }, + get(name) { + if (!global.sessionStorage) return null; + const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + if (!raw) return null; + const payload = JSON.parse(raw); + return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null; + }, + discard(name) { + if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + return true; + } + }); + + const recoveryMutationTails = new Map(); + function enqueueRecoveryMutation(logicalKey, task) { + const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve(); + const result = previous.then(task, task); + recoveryMutationTails.set(logicalKey, result.catch(() => undefined)); + return result; + } + + async function readRecovery(kind, id) { + await ready; + const items = await pruneRecoveryKey(recoveryKey(kind)); + return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null; + } + async function saveRecovery(kind, value, options = {}) { + await ready; assertObject(value, `recovery ${kind} value must be an object`); + const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value); + const key = recoveryKey(kind); + const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId); + const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() }); + const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + if (index >= 0) current.items[index] = item; else current.items.push(item); + return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation); + })); + const committedItem = (await kernel.read(key)) + .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + return Object.assign({}, receipt, { item: clone(committedItem || item) }); + } + async function discardRecovery(kind, id, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id)); + return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation); + })); + } + async function clearRecovery(kind, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) { + throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision }); + } + return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation); + })); + } + async function clearAllRecovery(options = {}) { + const results = {}; + for (const kind of Object.keys(RECOVERY_KEYS)) { + results[kind] = await clearRecovery(kind, options); + } + return results; + } + const recovery = Object.freeze({ + windowSession, + async clear(options = {}) { return clearAllRecovery(options); }, + async listActiveSessions() { return readRecovery('activeSession'); }, + async getActiveSession(id) { return readRecovery('activeSession', id); }, + async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); }, + async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async listDrafts() { return readRecovery('draft'); }, + async getDraft(id) { return readRecovery('draft', id); }, + async saveDraft(value, options) { return saveRecovery('draft', value, options); }, + async discardDraft(id, options) { return discardRecovery('draft', id, options); }, + async listInterrupted() { return readRecovery('interrupted'); }, + async getInterrupted(id) { return readRecovery('interrupted', id); }, + async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); }, + async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); }, + async listRejectedCompletions() { return readRecovery('rejectedCompletion'); }, + async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); }, + async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); }, + async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); } + }); + + function isImportableEntry(entry) { + return entry + && entry.classification !== 'system' + && entry.classification !== 'session' + && entry.import !== 'ignore'; + } + + function isPlainImportObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function isV2SnapshotShape(parsed) { + return isPlainImportObject(parsed) + && parsed.format === 'ielts-atlas-data-v2' + && isPlainImportObject(parsed.envelopes) + && isPlainImportObject(parsed.entities); + } + + const POISONED_V2_WRAPPER_ALIASES = Object.freeze({ + 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']), + 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']), + 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state']) + }); + const LIBRARY_IMPORT_KEYS = Object.freeze([ + 'library.configurations', + 'library.importedIndexes', + 'library.activeConfigurationId' + ]); + + function parseLegacyImportValue(value) { + if (typeof internals.parseLegacyValue !== 'function') { + throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); + } + return internals.parseLegacyValue(value); + } + + function decodePoisonedDocument(logicalKey, wrapped) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; + if (!aliases || !isPlainImportObject(wrapped) + || !Object.prototype.hasOwnProperty.call(wrapped, 'key') + || !Object.prototype.hasOwnProperty.call(wrapped, 'value') + || !aliases.includes(String(wrapped.key))) { + return { matched: false, value: null }; + } + const decoded = parseLegacyImportValue(wrapped.value); + if (!isPlainImportObject(decoded)) return { matched: true, value: null }; + const overlay = {}; + for (const [key, value] of Object.entries(wrapped)) { + if (key === 'key' || key === 'value' || key === 'timestamp') continue; + overlay[key] = clone(value); + } + return { + matched: true, + value: Object.assign({}, decoded, overlay) + }; + } + + function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { + if (!envelope || envelope.state !== 'present') return envelope; + const wrapped = envelope.data; + const decoded = decodePoisonedDocument(logicalKey, wrapped); + if (!decoded.matched || !decoded.value) return envelope; + const entry = catalog.get(logicalKey); + const next = internals.makeEnvelope(entry, decoded.value, { + revision: Number(envelope.revision) || 1, + operationId: String(envelope.operationId || randomId('import-repair')), + updatedAt: envelope.updatedAt + }); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + return next; + } + + function validateLibraryImportBundle(envelopes) { + const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (!presentKeys.length) return { valid: true, presentKeys }; + if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { + return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; + } + const values = {}; + for (const key of LIBRARY_IMPORT_KEYS) { + const envelope = envelopes[key]; + values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; + } + const configurations = asArray(values['library.configurations']); + const indexes = asObject(values['library.importedIndexes']); + const configurationIds = new Set(); + for (const configuration of configurations) { + const source = asObject(configuration); + const id = idOf(source, ['id', 'key', 'configId']); + if (!id || !acceptedLibraryId(id) || source.builtIn === true + || !Array.isArray(indexes[id]) || !indexes[id].length) { + return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; + } + configurationIds.add(id); + } + for (const [id, index] of Object.entries(indexes)) { + if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { + return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; + } + } + const activeId = values['library.activeConfigurationId']; + if (activeId !== null && (!acceptedLibraryId(activeId) + || !configurationIds.has(String(activeId)) + || !Array.isArray(indexes[String(activeId)]) + || !indexes[String(activeId)].length)) { + return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; + } + return { valid: true, presentKeys }; + } + + function canonicalizeV2Import(parsed) { + const warnings = []; + const repairedKeys = []; + const ignoredKeys = []; + const envelopes = {}; + for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + if (logicalKey === 'library.activeConfigurationId' + && rawEnvelope && rawEnvelope.state === 'present' + && String(rawEnvelope.data) === '[object Object]') { + ignoredKeys.push(logicalKey); + warnings.push('Skipped poisoned active library id'); + continue; + } + const repairCount = repairedKeys.length; + const repaired = repairPoisonedImportEnvelope( + logicalKey, + clone(rawEnvelope), + repairedKeys, + warnings + ); + const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; + const isLegacyRowWrapper = isPlainImportObject(rawData) + && Object.prototype.hasOwnProperty.call(rawData, 'key') + && Object.prototype.hasOwnProperty.call(rawData, 'value') + && String(rawData.key || '').startsWith('exam_system_'); + if (isLegacyRowWrapper && repairedKeys.length === repairCount) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + envelopes[logicalKey] = repaired; + } + const library = parsed.scope === 'full' + ? validateLibraryImportBundle(envelopes) + : { valid: true, presentKeys: [] }; + if (!library.valid) { + for (const logicalKey of library.presentKeys) { + delete envelopes[logicalKey]; + ignoredKeys.push(logicalKey); + } + warnings.push(`Skipped unsafe library data: ${library.reason}`); + } + const exportableKeys = catalog.list() + .filter((entry) => entry.export === true && isImportableEntry(entry)) + .map((entry) => entry.logicalKey); + const missingKeys = parsed.scope === 'full' + ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) + : []; + if (parsed.scope === 'full' && missingKeys.length) { + warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); + } + return { + envelopes, + warnings, + repairedKeys, + ignoredKeys, + missingKeys, + declaredScope: parsed.scope, + effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, + trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length + ? 'trusted-full' + : 'degraded-partial' + }; + } + + function resolveImportReplaceFlags(options = {}) { + const source = asObject(options); + const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase(); + const replaceAll = source.replace === true; + return { + replaceDocuments: replaceAll, + // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge. + replacePractice: replaceAll || practiceMode === 'replace' + }; + } + + function pickFirstRecordArray(candidates) { + for (const candidate of asArray(candidates)) { + if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) { + return { source: candidate.source, records: candidate.records }; + } + } + return null; + } + + /** + * Historical v1 export shapes (opensource / pre-AppData-v2): + * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats } + * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? } + * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*) + * - bare array of records, or { records: [...] } + * Recognition only — no dual backend and no local store migration. + */ + function extractLegacyPracticeRecords(payload) { + const sources = []; + const add = (source, records) => { + if (Array.isArray(records) && records.some(isPlainImportObject)) { + sources.push({ source, records }); + } + }; + + if (Array.isArray(payload)) { + add('(root array)', payload); + } else if (isPlainImportObject(payload)) { + const preferred = pickFirstRecordArray([ + { source: 'practice_records', records: payload.practice_records }, + { source: 'practiceRecords', records: payload.practiceRecords }, + { source: 'records', records: payload.records } + ]); + if (preferred) add(preferred.source, preferred.records); + + const data = isPlainImportObject(payload.data) ? payload.data : null; + if (data) { + const nested = pickFirstRecordArray([ + { source: 'data.practice_records', records: data.practice_records }, + { source: 'data.practiceRecords', records: data.practiceRecords } + ]); + if (nested) add(nested.source, nested.records); + else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data); + else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data); + if (isPlainImportObject(data.exam_system_practice_records)) { + add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); + } + } + if (isPlainImportObject(payload.exam_system_practice_records)) { + add('exam_system_practice_records.data', payload.exam_system_practice_records.data); + } + } + + const seen = new Set(); + const records = []; + for (const entry of sources) { + for (const item of asArray(entry.records)) { + if (!isPlainImportObject(item)) continue; + const identity = idOf(item, ['id', 'recordId', 'sessionId']); + if (identity) { + if (seen.has(identity)) continue; + seen.add(identity); + } + records.push(item); + } + } + return { + records, + sources: sources.map((entry) => entry.source) + }; + } + + function entityRowFromLayer(recordId, data, operationId) { + const payload = jsonValue(data, 'import practice entity'); + return { + recordId: String(recordId), + revision: 1, + operationId: String(operationId || `import-${recordId}`), + updatedAt: nowIso(), + data: payload, + checksum: checksum(payload) + }; + } + + function convertLegacyPracticeImport(payload) { + const extracted = extractLegacyPracticeRecords(payload); + if (!extracted.records.length) { + throw new AppDataError( + 'VALIDATION', + 'Import file is neither a v2 snapshot nor a recognizable v1 practice export' + ); + } + + const entities = { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + }; + const warnings = []; + let skipped = 0; + + for (const raw of extracted.records) { + try { + const layers = splitPracticeRecord(raw); + const recordId = layers.summary.id; + const operationId = `import-v1-${recordId}`; + entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId)); + entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId)); + entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId)); + } catch (error) { + skipped += 1; + warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`); + } + } + + if (!entities.practiceSummaries.length) { + throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized'); + } + + const accepted = entities.practiceSummaries.length; + return { + format: 'v1', + scope: 'partial', + envelopes: {}, + entities, + checksum: null, + warnings, + practiceSummary: { + accepted, + importedCount: accepted, + skippedCount: skipped, + sources: extracted.sources.slice() + } + }; + } + + function parseImportPayload(payload) { + let parsed; + try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); } + catch (error) { + if (error instanceof AppDataError) throw error; + throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message }); + } + + // Bare record arrays are a historical import convenience (UI file pickers). + if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed); + if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object'); + + if (isV2SnapshotShape(parsed)) { + if (Number(parsed.schemaVersion) !== Number(catalog.version)) { + throw new AppDataError('VALIDATION', 'Import schema version mismatch'); + } + if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) { + throw new AppDataError('VALIDATION', 'Import checksum mismatch'); + } + if (parsed.scope !== 'full' && parsed.scope !== 'partial') { + throw new AppDataError('VALIDATION', 'Import scope must be full or partial'); + } + const scope = parsed.scope; + for (const [store, rows] of Object.entries(parsed.entities)) { + if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) { + throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`); + } + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) { + throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + } + } + } + if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) { + throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer'); + } + const canonical = canonicalizeV2Import(parsed); + return { + format: 'v2', + scope: canonical.effectiveScope, + declaredScope: canonical.declaredScope, + envelopes: canonical.envelopes, + entities: parsed.entities, + checksum: parsed.checksum, + warnings: canonical.warnings, + practiceSummary: null, + repairedKeys: canonical.repairedKeys, + ignoredKeys: canonical.ignoredKeys, + missingKeys: canonical.missingKeys, + trust: canonical.trust + }; + } + + // Explicit but malformed v2 claims must not fall through to legacy parsers. + if (parsed.format === 'ielts-atlas-data-v2') { + throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported'); + } + + return convertLegacyPracticeImport(parsed); + } + + function collectionIdentityFields(logicalKey) { + if (logicalKey === 'library.configurations') return ['id', 'key', 'configId']; + if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId']; + if (logicalKey === 'backups.entries') return ['id']; + if (logicalKey === 'vocab.words') return ['id', 'word', 'key']; + if (logicalKey === 'goals.items') return ['id', 'goalId']; + return ['id', 'sessionId', 'recordId']; + } + + function collectionIdentity(logicalKey, value) { + const identity = idOf(value, collectionIdentityFields(logicalKey)); + return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity; + } + + function mergeCollection(existing, incoming, logicalKey) { + const result = asArray(existing).map((item) => clone(item)); + const positions = new Map(); + result.forEach((item, index) => { + const identity = collectionIdentity(logicalKey, item); + if (identity) positions.set(identity, index); + }); + for (const rawItem of asArray(incoming)) { + const item = jsonValue(rawItem, `${logicalKey} item`); + const identity = collectionIdentity(logicalKey, item); + if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`); + if (positions.has(identity)) result[positions.get(identity)] = item; + else { + positions.set(identity, result.length); + result.push(item); + } + } + return result; + } + + function mergeImportValue(entry, existing, incoming) { + const policy = entry.import; + if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey); + if (policy === 'patch') { + if (Array.isArray(existing) || Array.isArray(incoming)) { + // Array-shaped keys should use merge-by-id; treat accidental patch as replace. + return clone(incoming); + } + return Object.assign({}, asObject(existing), asObject(incoming)); + } + if (policy === 'replace') return clone(incoming); + throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`); + } + + async function currentEntitySnapshot() { + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(null, { withMeta: true }); + } + const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); + const result = {}; + for (const store of PRACTICE_ENTITY_STORES) { + if (store === 'practiceSummaries') result[store] = summaries; + else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean); + } + return result; + } + function practiceEntityIds(rows) { + return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean)); + } + function assertPracticeEntitySetsMatch(entities, message) { + const expected = practiceEntityIds(entities.practiceSummaries); + for (const store of PRACTICE_ENTITY_STORES.slice(1)) { + const actual = practiceEntityIds(entities[store]); + if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) { + throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', { + counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size])) + }); + } + } + } + async function createImportPlan(parsed, options = {}) { + const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options); + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} }; + const revisionToken = { documents: {}, entities: {} }; + const keys = []; const clearedKeys = []; + const warnings = asArray(parsed.warnings).map(String); + for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue; + if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`); + if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) { + warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); + continue; + } + const currentRead = await kernel.read(logicalKey, { withMeta: true }); + const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; + const currentEnvelope = currentRead && currentRead.envelope; + revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + let next = envelope; + if (!replaceDocuments && envelope.state === 'present') { + next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + } + snapshot.envelopes[logicalKey] = next; + keys.push(logicalKey); + if (next.state === 'cleared') clearedKeys.push(logicalKey); + } + + // Any successful practice import installs all three stores together. Merge + // may update a subset only when the final recordId sets remain identical. + const sourceStores = Object.keys(asObject(parsed.entities)); + let practiceExistingCount = null; + let practiceIncomingCount = null; + if (sourceStores.length) { + if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) { + throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations'); + } + const current = await currentEntitySnapshot(); + revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries( + asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0]) + )])); + practiceExistingCount = asArray(current.practiceSummaries).length; + practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length; + const existing = replacePractice + ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []])) + : current; + for (const store of PRACTICE_ENTITY_STORES) { + const rows = asArray(existing[store]).map(clone); + const positions = new Map(rows.map((row, index) => [String(row.recordId), index])); + for (const row of asArray(parsed.entities[store])) { + if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + const index = positions.get(String(row.recordId)); + if (index === undefined) { + positions.set(String(row.recordId), rows.length); + rows.push(clone(row)); + } else rows[index] = clone(row); + } + snapshot.entities[store] = rows; + } + assertPracticeEntitySetsMatch(snapshot.entities); + } + + snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities }); + const practiceSummary = parsed.practiceSummary + ? clone(parsed.practiceSummary) + : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries') + ? { + accepted: Number(practiceIncomingCount) || 0, + importedCount: Number(practiceIncomingCount) || 0, + skippedCount: 0, + existingCount: Number(practiceExistingCount) || 0, + incomingCount: Number(practiceIncomingCount) || 0, + finalCount: asArray(snapshot.entities.practiceSummaries).length, + removedCount: Math.max(0, (Number(practiceExistingCount) || 0) + - asArray(snapshot.entities.practiceSummaries).length) + } + : null); + if (practiceSummary && practiceSummary.existingCount === undefined) { + practiceSummary.existingCount = Number(practiceExistingCount) || 0; + practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0; + practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length; + practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount); + } + const destructive = clearedKeys.length > 0 + || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0); + return { + snapshot, + keys, + clearedKeys, + warnings, + practiceSummary, + destructive, + resetJournal: replaceDocuments && replacePractice, + revisionToken, + diagnostics: { + format: parsed.format, + replaceDocuments, + replacePractice, + declaredScope: parsed.declaredScope || parsed.scope, + effectiveScope: parsed.scope, + trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'), + missingKeys: clone(parsed.missingKeys || []), + repairedKeys: clone(parsed.repairedKeys || []), + ignoredKeys: clone(parsed.ignoredKeys || []) + } + }; + } + async function createRestoreSnapshot(backup) { + const parsed = parseImportPayload(asObject(backup && backup.data)); + if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups'); + if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch'); + return (await createImportPlan(parsed, { replace: true })).snapshot; + } + + const backups = Object.freeze({ + onDataCommitted(listener) { return kernel.onCommitted(listener); }, + async getSettings() { await ready; return kernel.read('backups.settings'); }, + async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); }, + async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); }, + async getImportHistory() { await ready; return kernel.read('backups.importHistory'); }, + async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); }, + async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); }, + async create(options = {}) { + await ready; const current = await readCollectionMeta('backups.entries'); + const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' }); + const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup')); + const existing = current.items.find((item) => String(item.id) === String(backupId)); + if (existing) { + if (String(existing.operationId || '') === String(mutation.operationId) + && String(existing.type || 'manual') === String(options.type || 'manual')) { + return clone(existing); + } + throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, { + backupId: String(backupId) + }); + } + const snapshot = await kernel.exportSnapshot(); + const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum }; + current.items.unshift(backup); + current.items = retainBackupEntries(current.items, 20, options.preserveIds); + await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation); + const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId)); + return clone(committed || backup); + }, + async list() { await ready; return kernel.read('backups.entries'); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); }, + async export(options = {}) { + await ready; + if (options.backupId !== undefined && options.backupId !== null) { + const backupId = String(options.backupId); + const stored = asArray(await kernel.read('backups.entries')) + .find((item) => String(item && item.id) === backupId); + if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`); + const portable = jsonValue(stored, 'stored backup export'); + if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) { + throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`); + } + return portable; + } + const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null; + const logicalKeys = domains + ? catalog.list() + .filter((entry) => domains.has(entry.owner) && entry.export === true) + .map((entry) => entry.logicalKey) + : null; + const entityStores = !domains || domains.has('practice') + ? undefined + : []; + return kernel.exportSnapshot(Object.assign( + logicalKeys ? { logicalKeys } : {}, + entityStores ? { entityStores } : {} + )); + }, + async previewImport(payload, options = {}) { + await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan'); + const cutoff = Date.now() - (30 * 60 * 1000); + for (const [id, existing] of importPlans) { + if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id); + } + const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) }; + importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive }; + }, + async commitImport(planId, options = {}) { + await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`); + if (plan.destructive && options.confirmDestructive !== true) { + throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation'); + } + const mutation = optionsMutationOptions(options, 'import-commit', { + planId: plan.id, + signature: plan.signature + }, { warnings: plan.warnings }); + const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, { + resetJournal: plan.resetJournal === true, + expectedRevisionToken: plan.revisionToken + })); + importPlans.delete(String(planId)); + return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) }); + }, + async restore(id, options = {}) { + await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id)); + if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`); + const snapshot = await createRestoreSnapshot(backup); + const restoreMutation = optionsMutationOptions(options, 'backup-restore', { + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }, { resetJournal: true }); + const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`; + const preRestoreBackupId = `pre_restore_${checksum({ + operationId: restoreMutation.operationId, + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }).replace(/[^a-z0-9]/gi, '')}`; + const preRestoreBackup = await backups.create({ + id: preRestoreBackupId, + operationId: preRestoreOperationId, + type: 'pre-restore', + preserveIds: [String(id)] + }); + const receipt = await kernel.installSnapshot(snapshot, restoreMutation); + return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id }); + } + }); + + let vocabMutationTail = Promise.resolve(); + function enqueueVocabMutation(task) { + const result = vocabMutationTail.then(task, task); + vocabMutationTail = result.catch(() => undefined); + return result; + } + function retryVocabMutation(options, task) { + return enqueueVocabMutation(() => retryMergeConflict(options, task)); + } + + const vocab = Object.freeze({ + async listWords() { await ready; return kernel.read('vocab.words'); }, + async saveWords(words, options = {}) { + await ready; assertArray(words, 'vocab.saveWords requires an array'); + const mutation = optionsMutationOptions(options, 'vocab-words', words); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.words', { withMeta: true }); + return mutateAndProject([{ + logicalKey: 'vocab.words', + data: words, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async getConfig() { await ready; return kernel.read('vocab.userConfig'); }, + async setConfig(config, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'vocab-config', config); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: asObject(config), + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async patchConfig(patch, options = {}) { + await ready; assertObject(patch, 'vocab.patchConfig requires an object'); + const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), clone(patch)); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); }, + async listCollections() { await ready; return kernel.read('vocab.lists'); }, + async saveCollection(id, value, options = {}) { + await ready; + const collectionId = String(id); + const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async saveCollections(values, options = {}) { + await ready; + assertObject(values, 'vocab.saveCollections requires an object'); + const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)])); + const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), upserts); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async upsertCollectionWord(collectionId, word, options = {}) { + await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word'); + const id = String(collectionId || ''); + if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required'); + const identity = String(word.word || word.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + const existing = collections[id]; + const list = existing && typeof existing === 'object' && !Array.isArray(existing) + ? Object.assign({}, clone(existing), { words: asArray(existing.words) }) + : { id, words: asArray(existing) }; + const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity); + const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() }); + if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt; + if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); + list.updatedAt = nowIso(); + collections[id] = list; + const receipt = await mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(nextWord) }); + }); + }, + async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; }, + async replaceListWords(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceListWords requires a command'); + const id = String(command.listId || 'default'); const words = asArray(command.words); + if (id === 'default') return this.saveWords(words, options); + const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async mergeListWords(command, options = {}) { + await ready; + assertObject(command, 'vocab.mergeListWords requires a command'); + const listId = String(command.listId || 'default'); + const incoming = asArray(command.words); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions(options, 'vocab-words-merge', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data)); + const storedList = listId === 'default' + ? asArray(current.data) + : (function readStoredCollection() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? asArray(collection.words) + : asArray(collection); + }()); + const merged = storedList.map((word) => clone(word)); + const positions = new Map(); + merged.forEach((word, index) => { + const identity = String(word && (word.word || word.id) || '').trim().toLowerCase(); + if (identity) positions.set(identity, index); + }); + let addedCount = 0; + let updatedCount = 0; + for (const rawWord of incoming) { + assertObject(rawWord, 'vocab.mergeListWords entries must be objects'); + const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + if (!positions.has(identity)) { + positions.set(identity, merged.length); + merged.push(clone(rawWord)); + addedCount += 1; + continue; + } + const index = positions.get(identity); + const existing = asObject(merged[index]); + const patch = {}; + if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim(); + if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim(); + if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq; + merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() }); + updatedCount += 1; + } + const data = listId === 'default' + ? merged + : Object.assign({}, collections, { + [listId]: Object.assign( + {}, + (function collectionBaseForWrite() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? clone(collection) + : {}; + }()), + { id: listId, words: merged, updatedAt: nowIso() } + ) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount }); + }); + }, + async patchWord(command, options = {}) { + await ready; assertObject(command, 'vocab.patchWord requires a command'); + const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || ''); + if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required'); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions( + Object.assign({}, options, { operationId: command.operationId || options.operationId }), + 'vocab-word-patch', + command + ); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : asObject(current.data); + const list = listId === 'default' + ? asArray(current.data) + : asArray(asObject(collections[listId]).words); + const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId); + if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`); + const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() }); + const next = list.slice(); next[index] = updated; + const data = listId === 'default' + ? next + : Object.assign({}, collections, { + [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(updated) }); + }); + }, + async replaceProgress(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceProgress requires a command'); + const listId = String(command.listId || 'default'); const words = asArray(command.words); + const mutation = optionsMutationOptions(options, 'vocab-progress', command); + return retryVocabMutation(options, async () => { + const configMeta = await kernel.read('vocab.userConfig', { withMeta: true }); + const changes = [{ + logicalKey: 'vocab.userConfig', + data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }), + expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0 + }]; + if (listId === 'default') { + const wordsMeta = await kernel.read('vocab.words', { withMeta: true }); + changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 }); + } else { + const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data)); + lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); + changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); + } + return mutateAndProject(changes, mutation); + }); + } + }); + + async function readPreferences() { await ready; return kernel.read('preferences.values'); } + let preferenceMutationTail = Promise.resolve(); + function enqueuePreferenceMutation(task) { + const result = preferenceMutationTail.then(task, task); + preferenceMutationTail = result.catch(() => undefined); + return result; + } + async function writePreference(field, value, options = {}) { + const mutation = optionsMutationOptions(options, 'preference-set', { field, value }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [field]: clone(value) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + async function patchPreference(field, patch, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const values = asObject(current.data); + const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + const preferences = Object.freeze({ + async getAll() { return readPreferences(); }, + async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); }, + async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); }, + async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); }, + async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); }, + async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); } + ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); }, + async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); }, + async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); }, + async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); }, + async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); }, + async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); }, + async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); }, + async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); } + }); + + const goals = Object.freeze({ + async list() { await ready; return kernel.read('goals.items'); }, + async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); } + }); + + function deliveryTimestamp(value) { + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN; + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function mergeDeliveryAcknowledgements(current, incoming) { + const merged = Object.assign({}, asObject(current)); + for (const [id, value] of Object.entries(asObject(incoming))) { + const key = String(id).trim(); + if (!key) continue; + const previous = deliveryTimestamp(merged[key]); + const next = deliveryTimestamp(value); + if (!hasOwn(merged, key) || (next && (!previous || next < previous))) { + merged[key] = next; + } else if (previous) { + merged[key] = previous; + } else { + merged[key] = null; + } + } + return merged; + } + + const achievements = Object.freeze({ + async getAll() { + await ready; + const progress = await retryMergeConflict({}, async () => { + const [summaries, manual, current] = await Promise.all([ + kernel.listEntities('practiceSummaries'), + kernel.read('achievements.manual'), + kernel.read('achievements.progress', { withMeta: true }) + ]); + const projected = asObject(computeAchievementProgress(summaries, manual, current.data)); + if (checksum(projected) !== checksum(asObject(current.data))) { + await kernel.mutate([{ + logicalKey: 'achievements.progress', + data: projected, + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], { + operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}` + }); + } + return projected; + }, 5); + if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh; + Object.defineProperty(progress, 'fresh', { value: true, enumerable: false }); + return progress; + }, + async retryPending() { return achievements.getAll(); }, + async acknowledgeDelivery(unlocked, options = {}) { + await ready; + assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object'); + const requested = clone(unlocked); + const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested); + return retryMergeConflict({}, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + const settingsValue = asObject(current.data); + const delivery = asObject(settingsValue.achievementDelivery); + const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested); + return kernel.mutate([{ + logicalKey: 'settings.values', + data: Object.assign({}, settingsValue, { + achievementDelivery: { version: 1, acknowledged } + }), + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], mutation); + }, 5); + }, + async getManualState() { await ready; return kernel.read('achievements.manual'); } + }); + + const LEGACY_DOCUMENT_ALIASES = Object.freeze({ + 'settings.values': ['user_settings', 'settings', 'system_settings'], + 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'], + 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'], + 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'], + 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'], + 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'], + 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], + 'achievements.manual': ['achievement_manual_state', 'user_achievements'] + }); + const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ + 'recovery.activeSessions', + 'recovery.drafts', + 'recovery.interrupted', + 'recovery.rejectedCompletions' + ]); + const LEGACY_PREFERENCE_ALIASES = Object.freeze({ + theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', + practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', + ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' + }); + + function legacyRecordArray(value) { + return Array.isArray(value) ? value : asArray(asObject(value).data); + } + function mergeLegacyExternalBackup(legacyValue, externalValue) { + const legacy = Object.assign({}, asObject(legacyValue)); + const external = asObject(externalValue); + const externalRecordKey = ['practice_records', 'practiceRecords'] + .find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (externalRecordKey) { + const records = new Map(); + for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { + const recordId = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); + } + legacy.practice_records = Array.from(records.values()); + } + for (const [target, aliases] of Object.entries({ + user_stats: ['user_stats', 'userStats'], + exam_index: ['exam_index', 'examIndex'], + storage_version: ['storage_version', 'storageVersion'] + })) { + if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (alias) legacy[target] = clone(external[alias]); + } + return legacy; + } + + function acceptedLibraryId(value) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; + try { return importedLibraryId(id); } catch (_) { return null; } + } + + function remapLegacyLibraryId(value) { + return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + } + + async function migrateLegacyLibraryData(legacy) { + const [configMeta, indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.configurations', { withMeta: true }), + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const legacyIdMap = new Map(); + const indexes = {}; + const addLegacyIndex = (oldId, value) => { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; + const index = asArray(value); + if (!index.length) return; + const mappedId = remapLegacyLibraryId(oldId); + legacyIdMap.set(oldId, mappedId); + indexes[mappedId] = clone(index); + }; + + for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); + // Reconciliation is a union. A healthy current v2 value wins for the same + // deterministic library ID, while missing legacy libraries are restored. + for (const [id, value] of Object.entries(asObject(indexMeta.data))) { + if (/^exam_index_/.test(id)) addLegacyIndex(id, value); + else { + const acceptedId = acceptedLibraryId(id); + if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); + } + } + + const configurations = new Map(); + const addConfiguration = (configuration) => { + const source = asObject(configuration); + const oldId = idOf(source, ['id', 'key', 'configId']); + if (!oldId || oldId === 'exam_index') return; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + if (!id || !asArray(indexes[id]).length) return; + configurations.set(id, Object.assign({}, clone(source), { + id, + key: id, + examCount: indexes[id].length + })); + }; + asArray(legacy.exam_index_configurations).forEach(addConfiguration); + asArray(configMeta.data).forEach(addConfiguration); + for (const [oldId, id] of legacyIdMap) { + if (!configurations.has(id)) { + configurations.set(id, { + id, + key: id, + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' + }); + } + } + + const resolveActive = (value) => { + const oldId = value === null || value === undefined ? '' : String(value).trim(); + if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + return id && asArray(indexes[id]).length ? id : null; + }; + const currentRawActive = activeMeta.data; + let activeId = resolveActive(currentRawActive); + const currentIsExplicitDefault = Boolean(activeMeta.envelope) + && (currentRawActive === null || String(currentRawActive || '').trim() === ''); + if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { + activeId = resolveActive(legacy.active_exam_index_key); + } + + const nextConfigurations = Array.from(configurations.values()); + const changes = []; + if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { + changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); + } + if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { + changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); + } + if (activeId !== activeMeta.data) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); + } + if (changes.length) { + await kernel.mutate(changes, { + operationId: `legacy-library-repair-v2-${checksum(changes)}` + }); + } + } + + async function migrateLegacyData() { + // Unit embedders may provide a deliberately minimal kernel bootstrap. + if (typeof internals.readLegacyValues !== 'function') return; + const legacySource = await internals.readLegacyValues(); + if (legacySource && legacySource.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); + const migrationState = asObject(migrationMeta.data); + let externalBackup = null; + if (asObject(migrationState.externalBackupV1).status !== 'consumed' + && typeof internals.readLegacyExternalBackup === 'function') { + try { + externalBackup = await internals.readLegacyExternalBackup(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); + } + } + const legacy = externalBackup + ? mergeLegacyExternalBackup(legacySource, externalBackup) + : legacySource; + if (!legacy || !Object.keys(legacy).length) return; + + const documentMetas = {}; + for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { + documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); + } + const [indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const documentRepairs = []; + const poisonedDocumentKeys = []; + for (const [logicalKey, current] of Object.entries(documentMetas)) { + if (!current.envelope || current.envelope.state !== 'present') continue; + const decoded = decodePoisonedDocument(logicalKey, current.data); + if (!decoded.matched) continue; + poisonedDocumentKeys.push(logicalKey); + const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; + const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + const legacyValue = legacyAlias ? legacy[legacyAlias] : null; + let repairValue = decoded.value; + if (isPlainImportObject(legacyValue)) { + repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); + } + if (!repairValue) continue; + documentRepairs.push({ + logicalKey, + data: repairValue, + expectedRevision: Number(current.envelope.revision) + }); + } + const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => + isPlainImportObject(value) + && Object.prototype.hasOwnProperty.call(value, 'key') + && Object.prototype.hasOwnProperty.call(value, 'value') + && /^exam_system_exam_index_/.test(String(value.key || ''))); + const poisonedActive = String(activeMeta.data) === '[object Object]'; + const libraryPoisoned = poisonedIndex || poisonedActive; + const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; + + await migrateLegacyLibraryData(legacy); + if (documentRepairs.length) { + await kernel.mutate(documentRepairs, { + operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` + }); + } + + const changes = []; + for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { + const currentAudit = asObject(migrationState.v1ToV2); + if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { + continue; + } + const current = await kernel.getEnvelope(logicalKey); + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + if (!alias) continue; + const legacyValue = legacy[alias]; + if (!current) { + changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); + continue; + } + const isBadMigrationWrite = current.state === 'present' + && /^legacy-documents-/.test(String(current.operationId || '')); + if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { + changes.push({ + logicalKey, + data: legacyValue, + expectedRevision: Number(current.revision) + }); + continue; + } + const entry = catalog.get(logicalKey); + if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; + let merged; + try { + merged = mergeImportValue(entry, legacyValue, current.data); + } catch (error) { + if (global.console && console.warn) { + console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); + } + continue; + } + if (checksum(merged) !== checksum(current.data)) { + changes.push({ + logicalKey, + data: merged, + expectedRevision: Number(current.revision) + }); + } + } + if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { + const preferences = {}; + for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) { + if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue; + const path = target.split('.'); let cursor = preferences; + path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; }); + cursor[path[path.length - 1]] = clone(legacy[alias]); + } + if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 }); + } + if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { + changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); + } + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + const recordsValue = legacy.practice_records; + const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); + const operations = []; + let skippedRecords = 0; + const reconciledRecordIds = new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + let canonical; + let parts; + try { + const candidate = jsonValue(record, 'legacy practice record'); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { + candidate.id = `legacy_${index}_${internals.checksum(record)}`; + } + canonical = canonicalizeRecord(candidate); + parts = splitPracticeRecord(canonical); + } catch (error) { + skippedRecords += 1; + if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); + continue; + } + if (reconciledRecordIds.has(canonical.id)) continue; + reconciledRecordIds.add(canonical.id); + // Storage errors are not malformed records. Let them abort this repair so the + // completion marker is not written and the next startup can retry safely. + const existing = await practiceLayers(canonical.id, true); + if (existing.summary && existing.detail && existing.annotations) continue; + operations.push(...practiceUpserts(canonical.id, parts, existing)); + } + if (operations.length) { + await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + } + const migrationAudit = { + version: 4, + status: 'complete', + mode: 'persistent-reconcile', + sourceChecksum: checksum(legacy), + sourceRecordCount: records.length, + skippedRecordCount: skippedRecords + }; + const currentAudit = asObject(migrationState.v1ToV2); + const comparableCurrentAudit = { + version: currentAudit.version, + status: currentAudit.status, + mode: currentAudit.mode, + sourceChecksum: currentAudit.sourceChecksum, + sourceRecordCount: currentAudit.sourceRecordCount, + skippedRecordCount: currentAudit.skippedRecordCount + }; + const externalAudit = externalBackup ? { + version: 1, + status: 'consumed', + sourceChecksum: checksum(externalBackup) + } : null; + const currentExternalAudit = asObject(migrationState.externalBackupV1); + if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) + || (externalAudit && (currentExternalAudit.status !== externalAudit.status + || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { + const nextMigrationState = Object.assign({}, migrationState, { + v1ToV2: Object.assign({}, migrationAudit, { + completedAt: nowIso(), + poisonDetected, + poisonedDocumentKeys, + libraryPoisoned + }) + }); + if (externalAudit) { + nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); + } + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { + operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` + }); + } + } + + const ready = kernel.initialize() + .then(async () => { + // Legacy migration and recovery cleanup are best-effort: a failure here + // (e.g. one malformed v1 record) must not brick the data layer for every + // read that awaits `ready`. Only a genuine backend init failure below is fatal. + try { + await migrateLegacyData(); + } catch (error) { + if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error); + } + try { + await cleanupExpiredRecovery(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error); + } + return true; + }) + .catch((error) => { + if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error); + throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed'); + }); + + const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements }; + Object.defineProperties(AppData, { + ready: { value: ready, enumerable: false }, + status: { value: () => kernel.status(), enumerable: false } + }); + Object.freeze(AppData); + Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false }); + if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) { + throw new Error('AppData v2 failed to close its internal bootstrap channel'); + } + if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) { + throw new Error('AppData v2 failed to close its catalog bootstrap channel'); + } +})(typeof window !== 'undefined' ? window : globalThis); + + /* ===== js/utils/answerMatchCore.js ===== */ (function initAnswerMatchCore(global) { 'use strict'; @@ -123,7 +4108,20 @@ function compareAnswers(userAnswer, correctAnswer) { const expected = splitAnswerTokens(correctAnswer); - const actual = splitAnswerTokens(userAnswer); + let actual = splitAnswerTokens(userAnswer); + + if ( + expected.length === 1 + && /^[A-Z]$/.test(expected[0]) + && actual.length === 1 + && !/^[A-Z]$/.test(actual[0]) + && typeof userAnswer === 'string' + ) { + const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/); + if (labeledOption) { + actual = [labeledOption[1]]; + } + } if (expected.length === 0 && actual.length === 0) { return null; @@ -269,12 +4267,11 @@ // 错误缓存,用于临时存储检测到的错误 this.errorCache = new Map(); - // 词表存储键配置 - this.storageKeys = { - p1: 'vocab_list_p1_errors', - p4: 'vocab_list_p4_errors', - master: 'vocab_list_master_errors', - custom: 'vocab_list_custom' + this.collectionIds = { + p1: 'spelling-errors-p1', + p4: 'spelling-errors-p4', + master: 'spelling-errors-master', + custom: 'custom' }; this.lexiconCache = null; @@ -292,17 +4289,8 @@ */ async init() { try { - // 等待存储系统就绪 - if (window.storage && window.storage.ready) { - await window.storage.ready; - } - - // 设置命名空间 - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[SpellingErrorCollector] 存储命名空间已设置'); - } - + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; this.initialized = true; console.log('[SpellingErrorCollector] 初始化完成'); } catch (error) { @@ -670,14 +4658,9 @@ try { await this.ensureInitialized(); - const storageKey = this.storageKeys[listId] || listId; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return null; - } - - const list = await window.storage.get(storageKey); + const collectionId = this.collectionIds[listId] || listId; + const collections = await window.AppData.vocab.listCollections(); + const list = collections[collectionId]; const normalizedList = this.normalizeVocabListShape(list, listId, listId); if (normalizedList) { @@ -689,7 +4672,7 @@ return null; } catch (error) { console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error); - return null; + throw error; } } @@ -701,31 +4684,10 @@ async saveVocabList(vocabList) { try { await this.ensureInitialized(); - - if (!vocabList || !vocabList.id) { - console.error('[SpellingErrorCollector] 无效的词表对象'); - return false; - } - - if (!Array.isArray(vocabList.words)) { - vocabList.words = []; - } - - vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; - - // 更新统计信息 - vocabList.stats = vocabList.stats || {}; - vocabList.stats.totalWords = vocabList.words.length; - vocabList.updatedAt = Date.now(); - - const storageKey = this.storageKeys[vocabList.id] || vocabList.id; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return false; - } - - await window.storage.set(storageKey, vocabList); + vocabList = this.prepareVocabList(vocabList); + if (!vocabList) return false; + const collectionId = this.collectionIds[vocabList.id] || vocabList.id; + await window.AppData.vocab.saveCollection(collectionId, vocabList); console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`); return true; @@ -735,6 +4697,19 @@ } } + prepareVocabList(vocabList) { + if (!vocabList || !vocabList.id) { + console.error('[SpellingErrorCollector] 无效的词表对象'); + return null; + } + if (!Array.isArray(vocabList.words)) vocabList.words = []; + const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; + normalized.stats = normalized.stats || {}; + normalized.stats.totalWords = normalized.words.length; + normalized.updatedAt = Date.now(); + return normalized; + } + /** * 获取词表单词数量 * @param {string} listId - 词表ID @@ -746,7 +4721,7 @@ return list ? list.words.length : 0; } catch (error) { console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error); - return 0; + throw error; } } @@ -1316,17 +5291,25 @@ try { await this.ensureInitialized(); await this.ensureCoreLexicon(); - - // 按来源分组错误 const errorsBySource = this.groupErrorsBySource(errors); - - // 保存到各个来源的词表 + const pendingCollections = {}; for (const [source, sourceErrors] of Object.entries(errorsBySource)) { - await this.saveErrorsToList(source, sourceErrors); + let vocabList = await this.loadVocabList(source); + if (!vocabList) vocabList = this.createEmptyList(source, source); + this.mergeErrorsToList(vocabList, sourceErrors); + const prepared = this.prepareVocabList(vocabList); + if (!prepared) throw new Error(`生成 ${source} 错词词表失败`); + pendingCollections[this.collectionIds[source] || source] = prepared; } - // 同步到综合词表 - await this.syncToMasterList(errors); + let masterList = await this.loadVocabList('master'); + if (!masterList) masterList = this.createEmptyList('master', 'all'); + this.mergeErrorsToList(masterList, errors); + const preparedMaster = this.prepareVocabList(masterList); + if (!preparedMaster) throw new Error('生成综合错词词表失败'); + pendingCollections[this.collectionIds.master] = preparedMaster; + + await window.AppData.vocab.saveCollections(pendingCollections); console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`); return true; @@ -1477,7 +5460,9 @@ ); if (vocabList.words.length < originalLength) { - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`); return true; } else { @@ -1507,7 +5492,9 @@ vocabList.words = []; vocabList.updatedAt = Date.now(); - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 清空词表: ${listId}`); return true; @@ -1530,11 +5517,328 @@ })(); +/* ===== js/utils/safeObjectLiteralParser.js ===== */ +(function (root, factory) { + 'use strict'; + + var api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root) { + root.SafeObjectLiteralParser = api; + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + var DEFAULT_LIMITS = Object.freeze({ + maxInputLength: 1024 * 1024, + maxDepth: 40, + maxProperties: 5000, + maxStringLength: 256 * 1024 + }); + function ParseError(message, index) { + this.name = 'SafeObjectLiteralParseError'; + this.message = message + ' at index ' + index; + this.index = index; + if (Error.captureStackTrace) Error.captureStackTrace(this, ParseError); + } + ParseError.prototype = Object.create(Error.prototype); + ParseError.prototype.constructor = ParseError; + + function makeLimits(options) { + options = options || {}; + var limits = {}; + Object.keys(DEFAULT_LIMITS).forEach(function (key) { + var configured = Number(options[key]); + limits[key] = Number.isFinite(configured) && configured > 0 + ? Math.floor(configured) + : DEFAULT_LIMITS[key]; + }); + return limits; + } + + function Parser(source, options) { + if (typeof source !== 'string') throw new TypeError('source must be a string'); + this.source = source; + this.length = source.length; + this.index = 0; + this.depth = 0; + this.propertyCount = 0; + this.limits = makeLimits(options); + if (this.length > this.limits.maxInputLength) { + throw new ParseError('input exceeds maximum length', 0); + } + } + + Parser.prototype.fail = function (message) { + throw new ParseError(message, this.index); + }; + + Parser.prototype.skipSpace = function () { + while (this.index < this.length) { + var ch = this.source.charAt(this.index); + if (/\s/.test(ch)) { + this.index++; + continue; + } + if (ch === '/' && this.source.charAt(this.index + 1) === '/') { + this.index += 2; + while (this.index < this.length && !/[\r\n]/.test(this.source.charAt(this.index))) { + this.index++; + } + continue; + } + if (ch === '/' && this.source.charAt(this.index + 1) === '*') { + var end = this.source.indexOf('*/', this.index + 2); + if (end < 0) this.fail('unterminated block comment'); + this.index = end + 2; + continue; + } + break; + } + }; + + Parser.prototype.enter = function () { + this.depth++; + if (this.depth > this.limits.maxDepth) this.fail('maximum nesting depth exceeded'); + }; + + Parser.prototype.leave = function () { + this.depth--; + }; + + Parser.prototype.countProperty = function () { + this.propertyCount++; + if (this.propertyCount > this.limits.maxProperties) { + this.fail('maximum property count exceeded'); + } + }; + + Parser.prototype.parseString = function () { + var quote = this.source.charAt(this.index++); + var result = ''; + while (this.index < this.length) { + var ch = this.source.charAt(this.index++); + if (ch === quote) return result; + if (ch === '\r' || ch === '\n') this.fail('unescaped newline in string'); + if (ch !== '\\') { + result += ch; + } else { + if (this.index >= this.length) this.fail('unterminated string escape'); + var escape = this.source.charAt(this.index++); + var simple = { + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '0': '\0', + '\\': '\\', + '/': '/', + '"': '"', + "'": "'" + }; + if (Object.prototype.hasOwnProperty.call(simple, escape)) { + if (escape === '0' && /[0-9]/.test(this.source.charAt(this.index))) { + this.fail('legacy octal escapes are not supported'); + } + result += simple[escape]; + } else if (escape === 'x') { + var hex = this.source.slice(this.index, this.index + 2); + if (!/^[0-9a-fA-F]{2}$/.test(hex)) this.fail('invalid hex escape'); + result += String.fromCharCode(parseInt(hex, 16)); + this.index += 2; + } else if (escape === 'u') { + var unicode = this.source.slice(this.index, this.index + 4); + if (!/^[0-9a-fA-F]{4}$/.test(unicode)) this.fail('invalid unicode escape'); + result += String.fromCharCode(parseInt(unicode, 16)); + this.index += 4; + } else { + this.fail('unsupported string escape'); + } + } + if (result.length > this.limits.maxStringLength) { + this.fail('string exceeds maximum length'); + } + } + this.fail('unterminated string'); + }; + + Parser.prototype.parseNumber = function () { + var remaining = this.source.slice(this.index); + var match = remaining.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/); + if (!match) this.fail('invalid number'); + var next = remaining.charAt(match[0].length); + if (next && /[A-Za-z0-9_$\.]/.test(next)) this.fail('invalid number suffix'); + this.index += match[0].length; + var value = Number(match[0]); + if (!Number.isFinite(value)) this.fail('non-finite numbers are not supported'); + return value; + }; + + Parser.prototype.parseIdentifier = function () { + var match = this.source.slice(this.index).match(/^[A-Za-z_$][A-Za-z0-9_$]*/); + if (!match) this.fail('expected identifier'); + this.index += match[0].length; + return match[0]; + }; + + Parser.prototype.parseKey = function () { + this.skipSpace(); + var ch = this.source.charAt(this.index); + var key; + if (ch === '"' || ch === "'") { + key = this.parseString(); + } else if (/[A-Za-z_$]/.test(ch)) { + key = this.parseIdentifier(); + } else { + var match = this.source.slice(this.index).match(/^(?:0|[1-9]\d*)/); + if (!match) this.fail('object keys must be quoted strings, identifiers, or integers'); + key = match[0]; + this.index += match[0].length; + } + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + this.fail('forbidden object key "' + key + '"'); + } + return key; + }; + + Parser.prototype.parseObject = function () { + var result = Object.create(null); + this.index++; + this.enter(); + this.skipSpace(); + if (this.source.charAt(this.index) === '}') { + this.index++; + this.leave(); + return result; + } + while (this.index < this.length) { + var key = this.parseKey(); + this.countProperty(); + this.skipSpace(); + if (this.source.charAt(this.index) !== ':') { + this.fail('object properties require a colon'); + } + this.index++; + result[key] = this.parseValue(); + this.skipSpace(); + var ch = this.source.charAt(this.index); + if (ch === '}') { + this.index++; + this.leave(); + return result; + } + if (ch !== ',') this.fail('expected comma or closing brace'); + this.index++; + this.skipSpace(); + if (this.source.charAt(this.index) === '}') { + this.index++; + this.leave(); + return result; + } + } + this.fail('unterminated object'); + }; + + Parser.prototype.parseArray = function () { + var result = []; + this.index++; + this.enter(); + this.skipSpace(); + if (this.source.charAt(this.index) === ']') { + this.index++; + this.leave(); + return result; + } + while (this.index < this.length) { + this.countProperty(); + result.push(this.parseValue()); + this.skipSpace(); + var ch = this.source.charAt(this.index); + if (ch === ']') { + this.index++; + this.leave(); + return result; + } + if (ch !== ',') this.fail('expected comma or closing bracket'); + this.index++; + this.skipSpace(); + if (this.source.charAt(this.index) === ']') { + this.index++; + this.leave(); + return result; + } + } + this.fail('unterminated array'); + }; + + Parser.prototype.parseValue = function () { + this.skipSpace(); + var ch = this.source.charAt(this.index); + if (ch === '{') return this.parseObject(); + if (ch === '[') return this.parseArray(); + if (ch === '"' || ch === "'") return this.parseString(); + if (ch === '-' || /[0-9]/.test(ch)) return this.parseNumber(); + if (/[A-Za-z_$]/.test(ch)) { + var identifier = this.parseIdentifier(); + if (identifier === 'true') return true; + if (identifier === 'false') return false; + if (identifier === 'null') return null; + this.fail('unsupported value "' + identifier + '"'); + } + this.fail('unsupported value'); + }; + + function parseAt(source, startIndex, options) { + var parser = new Parser(source, options); + parser.index = Math.max(0, Number(startIndex) || 0); + parser.skipSpace(); + if (parser.source.charAt(parser.index) !== '{') { + parser.fail('expected object literal'); + } + var value = parser.parseObject(); + return { value: value, endIndex: parser.index }; + } + + function parse(source, options) { + var parsed = parseAt(source, 0, options); + var parser = new Parser(source, options); + parser.index = parsed.endIndex; + parser.skipSpace(); + if (parser.index !== parser.length) parser.fail('unexpected trailing input'); + return parsed.value; + } + + return Object.freeze({ + ParseError: ParseError, + parse: parse, + parseAt: parseAt + }); +}); + + /* ===== js/listeningRecordBridge.js ===== */ (function () { 'use strict'; var TAG = '[ListeningBridge]'; + var HOST_MESSAGE_SOURCE = 'exam_host'; + + function deriveParentOriginFromReferrer() { + try { + if (!window.document || !window.document.referrer) return ''; + var parsed = new URL(window.document.referrer, window.location.href); + // Chromium: file URL.origin is "file://", postMessage event.origin is "null". + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (e) { + return ''; + } + } var state = { sessionId: null, @@ -1544,8 +5848,13 @@ initialized: false, completed: false, parentWindow: null, + expectedParentOrigin: deriveParentOriginFromReferrer(), + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', initRequestTimer: null, - initRequestAttempts: 0 + initRequestAttempts: 0, + pendingCompletion: null }; function log() { @@ -1570,6 +5879,22 @@ return null; } + function createSubmissionId() { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return 'listening-submit-' + window.crypto.randomUUID(); + } + if (window.crypto && typeof window.crypto.getRandomValues === 'function') { + var bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return 'listening-submit-' + Array.prototype.map.call(bytes, function (byte) { + return byte.toString(16).padStart(2, '0'); + }).join(''); + } + } catch (_) {} + return 'listening-submit-' + Date.now() + '-' + Math.random().toString(36).slice(2); + } + function sendMessage(type, data) { var pw = state.parentWindow || findParentWindow(); if (!pw) { @@ -1577,7 +5902,17 @@ return false; } try { - pw.postMessage({ type: type, data: data || {}, source: 'listening_record_bridge', timestamp: Date.now() }, '*'); + var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) { + warn('无法 send message — trusted parent origin is unavailable'); + return false; + } + var secureData = Object.assign({}, data || {}, { + windowSessionToken: state.windowSessionToken || null + }); + pw.postMessage({ type: type, data: secureData, source: 'listening_record_bridge', timestamp: Date.now() }, targetOrigin); return true; } catch (e) { warn('postMessage failed:', e); @@ -1860,30 +6195,11 @@ function parseObjectLiteral(text, startIndex, label) { label = label || 'inline'; if (startIndex >= text.length) return null; - var depth = 0; - var i = startIndex; - var started = false; - var objectStart = -1; - for (; i < text.length; i++) { - var ch = text.charAt(i); - if (ch === '{') { - if (!started) objectStart = i; - depth++; - started = true; - } - else if (ch === '}') { depth--; if (started && depth === 0) break; } - else if (ch === '\'' || ch === '"') { - var quote = ch; - for (i++; i < text.length; i++) { - if (text.charAt(i) === '\\' && i + 1 < text.length) { i++; continue; } - if (text.charAt(i) === quote) break; - } - } - } - if (!started || depth !== 0) return null; - var snippet = text.substring(objectStart, i + 1); try { - return (new Function('return (' + snippet + ')'))(); + if (!window.SafeObjectLiteralParser || typeof window.SafeObjectLiteralParser.parseAt !== 'function') { + throw new Error('SafeObjectLiteralParser is unavailable'); + } + return window.SafeObjectLiteralParser.parseAt(text, startIndex).value; } catch (e) { warn('parseObjectLiteral failed for', label, e); return null; @@ -2286,13 +6602,35 @@ }; } + function sendPendingCompletion(reason) { + var pending = state.pendingCompletion; + if (!pending || state.completed) return false; + if (!state.initialized || !state.windowSessionToken) { + sendInitRequest(reason || 'complete_before_init'); + return false; + } + if (!pending.payload) { + pending.payload = buildBridgePayload(pending.details); + pending.payload.submissionId = pending.submissionId; + } + log( + 'sending PRACTICE_COMPLETE, submissionId=' + pending.submissionId + + ' correct=' + pending.payload.scoreInfo.correct + '/' + pending.payload.scoreInfo.total + ); + return sendMessage('PRACTICE_COMPLETE', pending.payload); + } + function onComplete(options) { options = options || {}; if (state.completed) { log('already completed, skipping'); return true; } - state.completed = true; + if (state.pendingCompletion) { + sendPendingCompletion('completion_retry'); + scheduleCompletionRetries(state.pendingCompletion.options || options); + return true; + } var allowGenerated = !!options.allowGenerated; var details = extractAttemptDetails(window, { allowGenerated: allowGenerated }); @@ -2305,17 +6643,17 @@ } if (!details.length) { warn('no details extracted, cannot complete'); - state.completed = false; return false; } - var payload = buildBridgePayload(details); - log('sending PRACTICE_COMPLETE, correct=' + payload.scoreInfo.correct + '/' + payload.scoreInfo.total); - if (!state.initialized) { - sendInitRequest('complete_before_init'); - } - sendMessage('PRACTICE_COMPLETE', payload); - clearCompletionRetryTimers(); + state.pendingCompletion = { + submissionId: createSubmissionId(), + details: details, + options: Object.assign({}, options), + payload: null + }; + sendPendingCompletion(state.initialized ? 'completion_created' : 'complete_before_init'); + scheduleCompletionRetries(options); return true; } @@ -2335,9 +6673,9 @@ for (var i = 0; i < retryDelays.length; i++) { (function (delay) { completionRetryTimers.push(setTimeout(function () { - if (!state.completed) { - onComplete(options || {}); - } + if (state.completed) return; + if (state.pendingCompletion) sendPendingCompletion('completion_timeout'); + else onComplete(options || {}); }, delay)); })(retryDelays[i]); } @@ -2556,18 +6894,71 @@ if (type === 'INIT_SESSION' || type === 'init_exam_session') { var payload = data.data || data; - if (event.source && event.source !== window && typeof event.source.postMessage === 'function') { - state.parentWindow = event.source; + if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return; + var incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : ''; + var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : ''; + if (!incomingToken) return; + var expectedParentOrigin = state.expectedParentOrigin + && state.expectedParentOrigin !== 'file://' + && String(state.expectedParentOrigin).indexOf('file:') !== 0 + ? state.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return; + state.parentOrigin = expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else if (window.location.protocol === 'file:') { + var trustedFileOrigin = incomingOrigin === 'null' + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) return; + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } else { + var trustedWebOrigin = !!incomingOrigin + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) return; + state.parentOrigin = incomingOrigin; + state.parentOriginIsOpaque = false; } + var previousSessionId = state.sessionId; + state.windowSessionToken = incomingToken; state.sessionId = payload.sessionId || state.sessionId || (state.examId + '_' + Date.now()); state.examId = payload.examId || state.examId; state.suiteSessionId = payload.suiteSessionId || state.suiteSessionId || null; state.startTime = toTimestampMs(payload.startTime, toTimestampMs(state.startTime, Date.now())); state.initialized = true; stopInitRequestLoop(); + if (state.pendingCompletion && String(previousSessionId || '') !== String(state.sessionId || '')) { + state.pendingCompletion.payload = null; + } log('INIT_SESSION received — examId=' + state.examId + ' sessionId=' + state.sessionId); sendSessionReady('ready'); + if (state.pendingCompletion) { + sendPendingCompletion('init_received'); + } + } else if (type === 'PRACTICE_SUBMIT_ACK' || type === 'PRACTICE_SUBMIT_FAILED') { + var outcome = data.data || data; + if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return; + var outcomeOrigin = typeof event.origin === 'string' ? event.origin : ''; + if (state.parentOriginIsOpaque ? outcomeOrigin !== 'null' : (!state.parentOrigin || outcomeOrigin !== state.parentOrigin)) return; + if (!outcome || String(outcome.windowSessionToken || '') !== String(state.windowSessionToken || '')) return; + var pending = state.pendingCompletion; + if (!pending + || String(outcome.submissionId || '') !== String(pending.submissionId || '') + || String(outcome.sessionId || '') !== String(state.sessionId || '')) return; + if (type === 'PRACTICE_SUBMIT_ACK') { + state.completed = true; + state.pendingCompletion = null; + clearCompletionRetryTimers(); + log('PRACTICE_COMPLETE persisted, submissionId=' + outcome.submissionId); + } else { + warn('PRACTICE_COMPLETE persistence failed, retrying submissionId=' + outcome.submissionId); + scheduleCompletionRetries(pending.options || {}); + } } }); } @@ -2637,8 +7028,13 @@ (function markBundleProvided(global) { if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { global.AppLazyLoader.markProvided([ + "js/data/practiceRecordSource.js", + "js/data/v2/dataCatalog.js", + "js/data/v2/dataKernel.js", + "js/data/v2/appData.js", "js/utils/answerMatchCore.js", "js/app/spellingErrorCollector.js", + "js/utils/safeObjectLiteralParser.js", "js/listeningRecordBridge.js" ]); } diff --git a/js/bundles/listening-wrapper.bundle.js b/js/bundles/listening-wrapper.bundle.js index 4a1afee2..f9e25ec4 100644 --- a/js/bundles/listening-wrapper.bundle.js +++ b/js/bundles/listening-wrapper.bundle.js @@ -1,11 +1,3994 @@ /* Generated by scripts/build-bundles.mjs. Do not edit by hand. */ +/* ===== js/data/practiceRecordSource.js ===== */ +/** + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 + */ +(function initPracticeRecordSource(global) { + 'use strict'; + + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; + } + + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); + + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); + + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); + + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); + + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); + } + + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); + } + + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; + } + + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); + } + + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; + + if (demoSourceSet.has(readMetadataSource(record))) return false; + + return true; + } + + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } + + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } + + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); + + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } + + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; + } + + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; + } + return previewRecordIds.delete(normalizeId(recordId)); + } + + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); + } + + /** + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 + */ + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); + + global.PracticeRecordSource = api; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataCatalog.js ===== */ +(function installDataCatalog(global) { + 'use strict'; + + const V2_SCHEMA_VERSION = 2; + + function clone(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + + function objectDefault() { return {}; } + function arrayDefault() { return []; } + function nullableDefault() { return null; } + function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; } + function normalizeObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {}; + } + function normalizeNullableString(value) { + return value === null || value === undefined || value === '' ? null : String(value); + } + function isArray(value) { return Array.isArray(value); } + function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } + function isNullableString(value) { return value === null || typeof value === 'string'; } + + const CATALOG_OWNERS = new Set([ + 'settings', 'library', 'recovery', 'backups', 'vocab', + 'preferences', 'goals', 'achievements', 'system', 'practice' + ]); + const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']); + const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']); + + function isNonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); + } + + function ownerFromKey(logicalKey) { + const dot = String(logicalKey || '').indexOf('.'); + return dot > 0 ? logicalKey.slice(0, dot) : ''; + } + + function freezeEntry(entry) { + const logicalKey = String(entry.logicalKey || ''); + const owner = ownerFromKey(logicalKey); + const next = Object.assign({}, entry, { + logicalKey, + owner, + schemaVersion: V2_SCHEMA_VERSION, + export: entry.export === true, + import: entry.import || 'ignore' + }); + return Object.freeze(next); + } + + // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations), + // not as document keys. import merge identity is resolved in AppData, not here. + const definitions = [ + { + logicalKey: 'settings.values', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.configurations', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'library.importedIndexes', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.activeConfigurationId', classification: 'authoritative', + defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString, + export: true, import: 'replace' + }, + { + logicalKey: 'recovery.activeSessions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.drafts', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.interrupted', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.windowSession', classification: 'session', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.entries', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'merge-by-id' + }, + { + logicalKey: 'backups.settings', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'backups.exportHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.importHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'vocab.words', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'vocab.userConfig', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'vocab.lists', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'preferences.values', classification: 'preference', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'goals.items', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'achievements.manual', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'achievements.progress', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'system.migrations', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'system.operationJournal', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + } + ].map(freezeEntry); + + function validateCatalog(entries) { + if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry'); + const logicalKeys = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('DataCatalog entry must be an object'); + } + if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) { + throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`); + } + logicalKeys.add(entry.logicalKey); + } + for (const entry of entries) { + if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) { + throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`); + } + if (!CATALOG_CLASSIFICATIONS.has(entry.classification) + || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION + || typeof entry.defaultValue !== 'function' + || typeof entry.normalize !== 'function' + || typeof entry.validate !== 'function' + || typeof entry.export !== 'boolean' + || !IMPORT_POLICIES.has(entry.import)) { + throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`); + } + try { + const defaultValue = entry.defaultValue(); + if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) { + throw new Error('invalid default'); + } + } catch (_) { + throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`); + } + } + return true; + } + + validateCatalog(definitions); + const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry])); + const DataCatalog = Object.freeze({ + version: V2_SCHEMA_VERSION, + list() { return definitions.slice(); }, + get(logicalKey) { + const entry = byKey.get(String(logicalKey || '')); + if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`); + return entry; + }, + has(logicalKey) { return byKey.has(String(logicalKey || '')); }, + validate: validateCatalog, + clone + }); + + Object.defineProperty(global, '__AppDataV2Catalog', { + value: DataCatalog, + enumerable: false, + configurable: true, + writable: false + }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataKernel.js ===== */ +(function installDataKernel(global) { + 'use strict'; + + if (global.AppData) return; + + const catalog = global.__AppDataV2Catalog; + if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel'); + + const DATABASE_NAME = 'IELTSAtlasDataV2'; + // Version 2 uses a new schema, but initialization must still import the durable + // ExamSystemDB data owned by releases which predate AppData v2. + const DATABASE_VERSION = 2; + const DOCUMENT_STORE = 'documents'; + const SYSTEM_STORE = 'system'; + const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES)); + const OPERATION_JOURNAL_WINDOW = 500; + const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`; + const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000; + const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000; + const MAX_TIMER_DELAY_MS = 2147483647; + const LEGACY_DATABASE_NAME = 'ExamSystemDB'; + const LEGACY_STORE_NAME = 'keyValueStore'; + const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup'; + const LEGACY_EXTERNAL_STORE_NAME = 'handles'; + const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory'; + const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json'; + const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([ + 'practice_records', + 'vocab_user_config', + 'user_achievements' + ]); + + function clone(value) { return catalog.clone(value); } + function nowIso() { return new Date().toISOString(); } + function randomId(prefix) { + const random = global.crypto && typeof global.crypto.randomUUID === 'function' + ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `${prefix || 'op'}_${random}`; + } + + class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'AppDataError'; + this.code = code; + this.committed = false; + this.details = details; + } + } + function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); } + function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); } + + function normalizeTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback; + } + function scheduleTimeout(handler, delayMs) { + if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable'); + const handle = global.setTimeout.call(global, handler, delayMs); + if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle'); + return handle; + } + function cancelTimeout(handle) { + if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') { + try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ } + } + } + function withDeadline(handle, timeoutMs, description, resolve, reject) { + let settled = false; + let timer = null; + const settle = (callback, value) => { + if (settled) return; + settled = true; + cancelTimeout(timer); + callback(value); + }; + const expire = (error) => { + if (settled) return; + settled = true; + try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ } + reject(error); + }; + try { + timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, { + operation: description, timeoutMs, reason: 'timeout' + })), timeoutMs); + } catch (error) { + expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, { + operation: description, reason: 'watchdog-unavailable', cause: error && error.message + })); + } + return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } }; + } + + function canonicalizeJson(value, path = '$', ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path }); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') { + throw validation(`Non-JSON value at ${path}`, { path, type: typeof value }); + } + if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path }); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path }); + if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw validation(`Symbol-keyed property at ${path}`, { path }); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item, index) => { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path }); + return canonicalizeJson(item, `${path}[${index}]`, ancestors); + }); + } + const result = {}; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path }); + result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors); + } + return result; + } finally { ancestors.delete(value); } + } + function stableStringifyCanonical(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`; + } + function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); } + function checksum(value) { + const input = stableStringify(value); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + function legacyTimestamp(value) { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity; + if (Number.isFinite(Number(value))) return Number(value); + const parsed = Date.parse(value == null ? '' : String(value)); + return Number.isFinite(parsed) ? parsed : -Infinity; + } + function parseLegacyCandidate(value, outerTimestamp) { + let parsed = value; + let timestamp = legacyTimestamp(outerTimestamp); + const hasOuterTimestamp = timestamp !== -Infinity; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; } + } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') + && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) { + const innerTimestamp = legacyTimestamp(parsed.timestamp); + if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp; + parsed = parsed.data; + } else break; + } + return { value: clone(parsed), timestamp }; + } + function parseLegacyValue(value) { + const candidate = parseLegacyCandidate(value); + return candidate ? candidate.value : clone(value); + } + async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) { + const values = {}; + const candidates = {}; + let readComplete = true; + const consider = (alias, rawValue, timestamp, sourceRank) => { + const candidate = parseLegacyCandidate(rawValue, timestamp); + if (!candidate) return; + const previous = candidates[alias]; + if (!previous || candidate.timestamp > previous.timestamp + || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) { + candidates[alias] = Object.assign(candidate, { sourceRank }); + } + }; + if (indexedDBApi && typeof indexedDBApi.open === 'function') { + await new Promise((resolve) => { + let request; + let createdEmptyDatabase = false; + try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; } + request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); }; + request.onupgradeneeded = () => { + createdEmptyDatabase = true; + try { request.transaction.abort(); } catch (_) {} + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; } + const tx = db.transaction(LEGACY_STORE_NAME, 'readonly'); + const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys(); + const rows = tx.objectStore(LEGACY_STORE_NAME).getAll(); + tx.oncomplete = () => { + (keys.result || []).forEach((key, index) => { + const row = (rows.result || [])[index]; + // v1's keyValueStore persisted { key, value, timestamp } rows. + const validRow = row && typeof row === 'object' + && Object.prototype.hasOwnProperty.call(row, 'key') + && String(row.key) === String(key) + && Object.prototype.hasOwnProperty.call(row, 'value'); + if (!validRow) { + readComplete = false; + return; + } + consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0); + }); + db.close(); resolve(); + }; + tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); }; + }; + }); + } + for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) { + if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue; + for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) { + const key = fallbackStorage.key(index); + if (!key) continue; + const alias = key.startsWith('exam_system_') + ? key.slice('exam_system_'.length) + : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null); + if (!alias) continue; + try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ } + } + } + for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value; + Object.defineProperty(values, '__legacyReadComplete', { + value: readComplete, + enumerable: false, + configurable: false, + writable: false + }); + return values; + } + async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) { + if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null; + const directoryHandle = await new Promise((resolve) => { + let request; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value || null); + }; + try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; } + request.onerror = () => finish(null); + request.onupgradeneeded = () => { + try { request.transaction.abort(); } catch (_) {} + finish(null); + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) { + db.close(); finish(null); return; + } + const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly') + .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY); + get.onerror = () => { db.close(); finish(null); }; + get.onsuccess = () => { db.close(); finish(get.result); }; + }; + }); + if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null; + if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null; + const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false }); + const parsed = JSON.parse(await (await fileHandle.getFile()).text()); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined + ? parsed.data : parsed; + return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null; + } + function lookupEntry(logicalKey) { + if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey }); + return catalog.get(logicalKey); + } + function storeFor(logicalKey) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey }); + return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE; + } + function makeEnvelope(entry, data, options = {}) { + const state = options.state === 'cleared' ? 'cleared' : 'present'; + if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`); + let normalized = null; + if (state === 'present') { + try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) { + throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message }); + } + normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`); + if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey }); + } + const revision = options.revision === undefined ? 1 : Number(options.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`); + const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')), + updatedAt: options.updatedAt || nowIso(), state, data: normalized }; + payload.checksum = checksum(payload.data); + return Object.freeze(payload); + } + function validateEnvelope(entry, envelope) { + try { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) + || Number(envelope.schemaVersion) !== Number(entry.schemaVersion) + || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1 + || typeof envelope.operationId !== 'string' || !envelope.operationId + || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt + || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false; + const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`); + return (envelope.state !== 'cleared' || data === null) + && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data); + } catch (_) { return false; } + } + function operationId(value) { + if (value === undefined || value === null || value === '') return randomId('mutation'); + if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string'); + return value; + } + function expectedRevision(value, label) { + if (value === undefined || value === null) return null; + const revision = Number(value); + if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`); + return revision; + } + function compactJournal(journal) { + const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence)); + for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]]; + } + function readJournal(row) { + const envelope = row && row.envelope; + return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? clone(envelope.data) : {}; + } + function journalResult(journal, spec) { + const existing = journal[spec.operationId]; + if (!existing) return null; + if (existing.fingerprint !== spec.fingerprint || !existing.receipt) { + throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId }); + } + return clone(existing.receipt); + } + function writeJournal(journal, spec, receipt) { + const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1; + journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() }; + compactJournal(journal); + return journal; + } + function putJournal(tx, currentRow, journal, spec, receipt) { + const current = currentRow && currentRow.envelope; + const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), { + revision: current ? Number(current.revision) + 1 : 1, + operationId: spec.operationId, + normalized: true + }); + tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) }); + } + + class IndexedDBDriver { + constructor(indexedDBApi, options) { + this.indexedDB = indexedDBApi; + this.db = null; + this.mutationTimeoutMs = options.mutationTimeoutMs; + this.requestTimeoutMs = options.requestTimeoutMs; + } + async initialize() { + if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable'); + this.db = await new Promise((resolve, reject) => { + const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + let abandoned = false; + let settle; + request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion < 2) { + for (const name of ['authoritative', 'derived']) { + if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name); + } + } + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' }); + } + }; + settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject); + request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB')); + request.onblocked = () => { + abandoned = true; + settle.reject(new Error('IndexedDB upgrade blocked')); + }; + }); + this.db.onversionchange = () => this.close(); + return this; + } + close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} } + _open() { if (!this.db) throw new Error('IndexedDB connection closed'); } + _transaction(stores, mode, description, work, mutation = false) { + this._open(); + return new Promise((resolve, reject) => { + let failure = null; + let value; + let tx; + try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; } + const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject); + tx.oncomplete = () => settle.resolve(clone(value)); + tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); }; + tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`)); + const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} }; + try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); } + }); + } + readEnvelope(logicalKey) { + const store = storeFor(logicalKey); + return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(logicalKey); + request.onsuccess = () => done(request.result ? request.result.envelope : null); + request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`)); + }); + } + readEntity(store, recordId) { + return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(recordId); + request.onsuccess = () => done(request.result || null); + request.onerror = () => fail(request.error || new Error('Entity read failed')); + }); + } + readPracticeSnapshot(recordIds = null, options = {}) { + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES.slice(); + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean)); + return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => { + const result = Object.fromEntries(stores.map((store) => [store, []])); + let remaining = stores.length; + const finishStore = (store, rows) => { + result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || ''))); + remaining -= 1; + if (!remaining) done(result); + }; + for (const store of stores) { + const objectStore = tx.objectStore(store); + const request = requested && requested.size === 1 + ? objectStore.get(Array.from(requested)[0]) + : objectStore.getAll(); + request.onsuccess = () => { + const rows = requested && requested.size === 1 + ? (request.result ? [request.result] : []) + : request.result; + finishStore(store, rows); + }; + request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`)); + } + }); + } + listEntities(store) { + return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => { + const request = tx.objectStore(store).getAll(); + request.onsuccess = () => done(request.result || []); + request.onerror = () => fail(request.error || new Error('Entity list failed')); + }); + } + atomic(spec) { + return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => { + const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal'); + journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed')); + journalRequest.onsuccess = () => { + try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); } + }; + }, true); + } + exportSnapshot(envelopeKeys) { + return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => { + const result = { envelopes: {}, entities: {} }; + let remaining = STORE_NAMES.length; + for (const store of STORE_NAMES) { + const request = tx.objectStore(store).getAll(); + request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`)); + request.onsuccess = () => { + if (store === DOCUMENT_STORE || store === SYSTEM_STORE) { + for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope; + } else result.entities[store] = request.result || []; + remaining -= 1; + if (!remaining) done(result); + }; + } + }); + } + } + + function entityStore(store) { + const value = String(store || ''); + if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value }); + return value; + } + function validateEntityRow(store, row) { + if (!row || typeof row !== 'object' || Array.isArray(row) + || typeof row.recordId !== 'string' || !row.recordId + || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1 + || typeof row.operationId !== 'string' || !row.operationId + || typeof row.updatedAt !== 'string' || !row.updatedAt) { + throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null }); + } + const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`); + if (row.checksum !== checksum(data)) { + throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId }); + } + return row; + } + function normalizeEntityOperation(operation, index) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`); + const type = String(operation.type || ''); + const store = entityStore(operation.store); + if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`); + const recordId = type === 'clear' ? null : String(operation.recordId || ''); + if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`); + const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null; + return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) }; + } + function receiptFor(operationIdValue, revisions, warnings, pending) { + const receipt = { committed: true, revisions, operationId: operationIdValue, + derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() }; + const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]]; + return receipt; + } + + class DataKernel { + constructor(options = {}) { + this.driver = null; + this.backend = null; + this.state = 'created'; + this.failure = null; + this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB; + this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS); + this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS); + this.committedListeners = new Set(); + this.commitChannel = null; + this.instanceId = randomId('kernel'); + this.ready = null; + } + _initializeCommitChannel() { + if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return; + try { + const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME); + channel.onmessage = (message) => { + const data = message && message.data; + if (!data || data.sourceInstanceId === this.instanceId + || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return; + this._dispatchCommitted({ + operationId: data.operationId, + targets: clone(data.targets), + receipt: data.receipt ? clone(data.receipt) : null, + remote: true + }); + }; + this.commitChannel = channel; + } catch (_) { + this.commitChannel = null; + } + } + _closeCommitChannel() { + const channel = this.commitChannel; + this.commitChannel = null; + try { if (channel) channel.close(); } catch (_) {} + } + initialize() { + if (this.ready) return this.ready; + this.state = 'initializing'; + this.ready = new IndexedDBDriver(this.indexedDB, { + mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs + }).initialize().then((driver) => { + this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this; + }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null; + throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); }); + return this.ready; + } + close() { + if (this.driver) this.driver.close(); + this.driver = null; this.backend = null; + this._closeCommitChannel(); + if (this.state !== 'failed') this.state = 'closed'; + } + _assertReady() { + if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message }); + if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized'); + } + _latch(error) { + if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message }); + this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel(); + return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message }); + } + onCommitted(listener) { + if (typeof listener !== 'function') throw validation('Committed listener must be a function'); + this.committedListeners.add(listener); return () => this.committedListeners.delete(listener); + } + _dispatchCommitted(event) { + if (!event || !this.committedListeners.size) return; + const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback); + schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} })); + } + _notifyCommitted(targets, receipt) { + if (!targets.length) return; + const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false }; + this._dispatchCommitted(event); + if (this.commitChannel) { + try { + this.commitChannel.postMessage({ + sourceInstanceId: this.instanceId, + operationId: event.operationId, + targets: event.targets, + receipt: event.receipt + }); + } catch (_) { /* cross-realm notification is best effort */ } + } + } + async getEnvelope(logicalKey) { + this._assertReady(); const entry = lookupEntry(logicalKey); + try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async read(logicalKey, options = {}) { + const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey); + const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data; + return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data); + } + _documentSpec(changes, options) { + if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes'); + const opId = operationId(options.operationId); const seen = new Set(); + const prepared = changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`); + const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey); + if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel'); + if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey); + const state = change.state === 'cleared' ? 'cleared' : 'present'; + if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`); + if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`); + let data = null; + if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); } + return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) }; + }); + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings }); + return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) }; + } + async mutate(changes, options = {}) { + this._assertReady(); const spec = this._documentSpec(changes, options); + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) })); + let remaining = reads.length; + const finish = () => { + const revisions = {}; + for (const item of reads) { + const current = item.request.result ? item.request.result.envelope : null; + if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey }); + const revision = current ? Number(current.revision) : 0; + if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision }); + const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true }); + tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision; + } + const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []); + putJournal(tx, journalRow, journal, spec, receipt); + done(receipt); + }; + if (!remaining) { finish(); return; } + for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification })); + this._notifyCommitted(targets, receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); } + async readEntity(store, recordId, options = {}) { + this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId'); + try { + const row = await this.driver.readEntity(store, id); + if (!row) return null; + validateEntityRow(store, row); + return options.withMeta ? clone(row) : clone(row.data); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async readPracticeSnapshot(recordIds = null, options = {}) { + this._assertReady(); + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + try { + const snapshot = await this.driver.readPracticeSnapshot(ids, options); + const result = {}; + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES; + for (const store of stores) { + const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : []) + .filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + result[store] = options.withMeta + ? clone(validRows) + : validRows.map((row) => clone(row.data)); + } + return result; + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async listEntities(store, options = {}) { + this._assertReady(); store = entityStore(store); + if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId'); + try { + const rows = await this.driver.listEntities(store); + const validRows = rows.filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data)); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async mutateEntities(operations, options = {}) { + this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations'); + const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set(); + for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); } + for (const store of ENTITY_STORES) { + const scoped = items.filter((item) => item.store === store); + if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) { + throw validation(`Entity clear cannot be combined with other operations for ${store}`); + } + } + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length; + const finish = () => { const revisions = {}; + for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0; + if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`); + const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; } + } + for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; } + const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); }; + if (!remaining) { try { finish(); } catch (error) { fail(error); } return; } + for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async exportSnapshot(options = {}) { + this._assertReady(); + try { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null; + const shouldExport = (logicalKey) => { + if (!catalog.has(logicalKey)) return false; + const entry = lookupEntry(logicalKey); + if (selected && !selected.has(logicalKey)) return false; + if (entry.export === true) return true; + return options.includeSystem === true && entry.classification === 'system'; + }; + const data = await this.driver.exportSnapshot(shouldExport); + // Full/partial snapshots must be dense for their declared catalog + // range. An absent physical row means the catalog default, not an + // instruction that future importers should guess about. + for (const entry of catalog.list()) { + if (!shouldExport(entry.logicalKey) + || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue; + data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, { + state: 'cleared', + operationId: 'snapshot-default' + }); + } + if (Array.isArray(options.entityStores)) { + const selectedStores = new Set(options.entityStores.map(entityStore)); + for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store]; + } + const payload = { envelopes: data.envelopes, entities: data.entities }; + return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) }; + } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async installSnapshot(snapshot, options = {}) { + this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} }; + const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities'); + if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid'); + if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch'); + const changes = []; + for (const [logicalKey, envelope] of Object.entries(envelopes)) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue; + if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`); + changes.push({ logicalKey, entry, envelope }); + } + const entityRows = {}; + for (const store of ENTITY_STORES) { + if (!Object.prototype.hasOwnProperty.call(entities, store)) continue; + const rows = entities[store]; + if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`); + const ids = new Set(); + entityRows[store] = rows.map((row) => { + if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`); + const recordId = String(row.recordId); + if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`); + ids.add(recordId); + const data = canonicalizeJson(row.data); + if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`); + const revision = row.revision === undefined ? 1 : Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`); + return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) }; + }); + } + if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data'); + const resetJournal = options.resetJournal === true; + const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object' + ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken') + : null; + const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {}; + const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {}; + const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({ + kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey) + })).concat(Object.entries(entityChecks).map(([store, expected]) => ({ + kind: 'entities', store, expected, request: tx.objectStore(store).getAll() + }))); + const finish = () => { + for (const read of reads) { + if (read.kind === 'document') { + const current = read.request.result ? read.request.result.envelope : null; + const actualRevision = current ? Number(current.revision) : 0; + const expectedRevision = Number(read.expected) || 0; + if (actualRevision !== expectedRevision) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision }); + } + } else { + const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0])); + const expected = read.expected && typeof read.expected === 'object' ? read.expected : {}; + const ids = new Set(Object.keys(actual).concat(Object.keys(expected))); + for (const recordId of ids) { + const current = actual[recordId] || 0; + const wanted = Number(expected[recordId]) || 0; + if (current !== wanted) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId }); + } + } + } + } + const revisions = {}; + for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); } + for (const [store, rows] of Object.entries(entityRows)) { + tx.objectStore(store).clear(); + for (const row of rows) tx.objectStore(store).put(row); + } + const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt); + }; + if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; } + let remaining = reads.length; + for (const read of reads) { + read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed')); + read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; + } + } })); + const targets = changes + .filter((item) => item.entry.owner !== 'backups') + .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification })) + .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' }))); + this._notifyCommitted(targets, receipt); + return receipt; + } + catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); } + } + + Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/appData.js ===== */ +(function installAppData(global) { + 'use strict'; + + const internals = global.__AppDataV2Internals; + if (!internals || typeof internals.DataKernel !== 'function') { + throw new Error('AppData v2 requires DataKernel'); + } + const { + DataKernel, + AppDataError, + catalog, + clone, + randomId, + nowIso, + checksum + } = internals; + const kernel = new DataKernel(); + const importPlans = new Map(); + const RECOVERY_KEYS = Object.freeze({ + activeSession: 'recovery.activeSessions', + draft: 'recovery.drafts', + interrupted: 'recovery.interrupted', + rejectedCompletion: 'recovery.rejectedCompletions' + }); + const PREFERENCE_FIELDS = Object.freeze({ + theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode', + resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay', + threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget', + consent: 'consent', logConfig: 'logConfig' + }); + const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + + function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function asArray(value) { return Array.isArray(value) ? value : []; } + function idOf(value, fields) { + for (const field of fields) { + if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]); + } + return ''; + } + function importedLibraryId(value, options = {}) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id && options.nullable) return null; + if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required'); + if (/^exam_index(?:_|$)/.test(id)) { + throw new AppDataError('VALIDATION', 'Unsupported library configuration id'); + } + return id; + } + function assertObject(value, message) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function assertArray(value, message) { + if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function jsonValue(value, label = 'value') { + try { + const serialized = JSON.stringify(value, (_key, current) => { + if (typeof current === 'bigint') return String(current); + if (typeof current === 'number' && !Number.isFinite(current)) return null; + return current; + }); + if (serialized === undefined) return null; + return JSON.parse(serialized); + } catch (error) { + throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message }); + } + } + function operationId(command, prefix, semanticPayload = command) { + const id = command && command.operationId ? String(command.operationId) : randomId(prefix); + jsonValue(semanticPayload, `${prefix} payload`); + return id; + } + function mutationOptions(command, prefix, semanticPayload, extra = {}) { + const source = asObject(command); + const payload = jsonValue(semanticPayload, `${prefix} payload`); + const intent = { command: prefix, payload }; + if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) { + intent.expectedRevision = source.expectedRevision; + } + return Object.assign({}, extra, { + operationId: operationId(source, prefix, payload), + intent + }); + } + function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) { + return mutationOptions(asObject(options), prefix, semanticPayload, extra); + } + function deterministicEntityId(prefix, operation) { + return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`; + } + function normalizeAccuracyRatio(value, label = 'accuracy') { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`); + } + return numeric > 1 ? numeric / 100 : numeric; + } + function defaultStats() { + return { + totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0, + reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + lastUpdated: nowIso() + }; + } + + function nonNegativeScalar(value) { + if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + } + + function firstNonNegativeScalar(...values) { + for (const value of values) { + const numeric = nonNegativeScalar(value); + if (numeric !== null) return numeric; + } + return null; + } + + function normalizeAnswerQuestionId(value, index = 0) { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || `q${index + 1}`; + } + + function normalizeAnswerValue(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); + if (value && typeof value === 'object') { + if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); + if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); + if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); + return jsonValue(value, 'practice answer'); + } + return typeof value === 'string' ? value : String(value); + } + + function normalizeAnswerMap(value) { + const normalized = {}; + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry === undefined || entry === null) return; + const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; + const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); + normalized[questionId] = normalizeAnswerValue( + hasOwn(item, 'answer') ? item.answer + : hasOwn(item, 'userAnswer') ? item.userAnswer + : hasOwn(item, 'value') ? item.value + : entry + ); + }); + return normalized; + } + if (!value || typeof value !== 'object') return normalized; + Object.entries(value).forEach(([questionId, answer], index) => { + if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; + normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); + }); + return normalized; + } + + function mergeAnswerMaps(...sources) { + const merged = {}; + for (const source of sources) { + const normalized = normalizeAnswerMap(source); + for (const [questionId, answer] of Object.entries(normalized)) { + if (!hasOwn(merged, questionId)) merged[questionId] = answer; + } + } + return merged; + } + + function canonicalizeAnswerSource(record) { + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const rawRealData = asObject(rawData.realData); + record.answers = mergeAnswerMaps( + record.answers, + record.answerMap, + record.answerList, + realData.answers, + realData.answerMap, + rawData.answers, + rawData.answerMap, + rawRealData.answers + ); + // These names remain accepted only at the compatibility boundary. The + // canonical detail layer owns one user-answer source: `answers`. + delete record.answerMap; + delete record.answerList; + } + + function normalizePracticeScoreFields(record) { + const scoreInfo = asObject(record.scoreInfo); + const realScoreInfo = asObject(asObject(record.realData).scoreInfo); + const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); + const overloadedCorrectAnswers = record.correctAnswers; + const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; + if (isAnswerMap) { + const existingMap = record.correctAnswerMap; + const overloadedObject = asObject(overloadedCorrectAnswers); + const existingObject = asObject(existingMap); + if (Object.keys(overloadedObject).length) { + record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); + } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { + record.correctAnswerMap = clone(overloadedCorrectAnswers); + } + } + + let correctAnswers = firstNonNegativeScalar( + overloadedCorrectAnswers, + record.correctAnswersCount, + record.correctCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct, + rawScoreInfo.correctAnswers, + rawScoreInfo.correct + ); + if (correctAnswers === null) { + const comparisons = asArray(record.answerComparison).length + ? asArray(record.answerComparison) + : asArray(asObject(record.realData).answerComparison); + if (comparisons.length) { + correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; + } + } + if (correctAnswers !== null) record.correctAnswers = correctAnswers; + else if (isAnswerMap) record.correctAnswers = 0; + + const totalQuestions = firstNonNegativeScalar( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total, + rawScoreInfo.totalQuestions, + rawScoreInfo.total + ); + if (totalQuestions !== null) record.totalQuestions = totalQuestions; + } + + function canonicalizeRecord(input) { + assertObject(input, 'practice record must be an object'); + const record = jsonValue(input, 'practice record'); + record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record'); + record.sessionId = idOf(record, ['sessionId']) || record.id; + record.timestamp = record.timestamp || record.completedAt || record.date || nowIso(); + record.completedAt = record.completedAt || record.timestamp; + record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice'; + record.metadata = asObject(record.metadata); + if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; + if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; + canonicalizeAnswerSource(record); + normalizePracticeScoreFields(record); + for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { + if (record[field] === undefined || record[field] === null || record[field] === '') continue; + const numeric = Number(record[field]); + if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`); + record[field] = numeric; + } + if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy'); + return jsonValue(record, 'canonical practice record'); + } + + function deriveQuestionTypeErrorCounts(source) { + const record = asObject(source); + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const counts = {}; + const addCount = (type, value) => { + const key = String(type || 'other').trim() || 'other'; + const amount = Math.max(0, Number(value) || 0); + if (amount > 0) counts[key] = (counts[key] || 0) + amount; + }; + const performanceSources = [ + record.questionTypePerformance, + realData.questionTypePerformance, + rawData.questionTypePerformance + ]; + let hasPerformanceData = false; + for (const performanceMap of performanceSources) { + if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; + let sourceHasPerformanceData = false; + for (const [type, value] of Object.entries(performanceMap)) { + const performance = asObject(value); + const total = Number(performance.total ?? performance.totalQuestions); + const correct = Number(performance.correct ?? performance.correctAnswers); + if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; + hasPerformanceData = true; + sourceHasPerformanceData = true; + addCount(type, total - correct); + } + if (sourceHasPerformanceData) break; + } + if (hasPerformanceData) return counts; + + const questionTypeMap = Object.assign( + {}, + asObject(rawData.questionTypeMap), + asObject(realData.questionTypeMap), + asObject(record.questionTypeMap) + ); + const normalizedTypeMap = {}; + for (const [questionId, type] of Object.entries(questionTypeMap)) { + normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; + } + const detailSources = [ + record.answerDetails, + asObject(record.scoreInfo).details, + realData.answerDetails, + asObject(realData.scoreInfo).details, + rawData.answerDetails, + asObject(rawData.scoreInfo).details + ]; + const seenQuestions = new Set(); + for (const details of detailSources) { + if (!details || typeof details !== 'object' || Array.isArray(details)) continue; + for (const [questionId, value] of Object.entries(details)) { + const detail = asObject(value); + const normalizedId = String(questionId).trim().toLowerCase(); + if (!normalizedId || seenQuestions.has(normalizedId)) continue; + let isWrong = detail.isCorrect === false || detail.correct === false; + if (detail.isCorrect === true || detail.correct === true) isWrong = false; + else if (!isWrong) { + const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); + const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); + isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); + } + if (!isWrong) continue; + seenQuestions.add(normalizedId); + addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); + } + } + return counts; + } + + function lightSuiteEntry(source, fallbackType = null) { + const entry = asObject(source); + const scoreInfo = asObject(entry.scoreInfo); + const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); + const metadata = asObject(entry.metadata); + const totalQuestions = firstNonNegativeScalar( + entry.totalQuestions, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total + ) ?? 0; + const correctAnswers = firstNonNegativeScalar( + entry.correctAnswers, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct + ) ?? 0; + const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'suite entry accuracy' + ) || 0; + const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0; + return jsonValue({ + id: entry.id || null, + sessionId: entry.sessionId || null, + examId: entry.examId || metadata.examId || null, + title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', + type: entry.type || metadata.type || metadata.examType || fallbackType || null, + date: entry.date || entry.completedAt || entry.timestamp || null, + duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage, + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + }, 'suite entry light projection'); + } + + function lightFromCanonical(source) { + const scoreInfo = asObject(source.scoreInfo); + const realScoreInfo = asObject(asObject(source.realData).scoreInfo); + const metadata = asObject(source.metadata); + const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const dataSource = hasOwn(source, 'dataSource') + ? source.dataSource + : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined); + const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0; + const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0; + const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'practice light accuracy' + ) || 0; + return jsonValue({ + id: source.id, + sessionId: source.sessionId, + examId: source.examId || source.metadata.examId || null, + title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '', + type: source.type, + mode: source.mode || source.practiceMode || null, + timestamp: source.timestamp, + completedAt: source.completedAt, + date: source.date || source.completedAt || source.timestamp || null, + startTime: source.startTime || null, + endTime: source.endTime || null, + duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, + score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` + // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 + // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 + dataSource, + // Summaries are list indexes. Keep only the metadata needed to filter, show a + // source label, or locate the originating library; details stay in their entity. + metadata: Object.fromEntries([ + // `source` must stay: PracticeRecordSource uses metadata.source demo markers + // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full. + 'examId', 'examTitle', 'title', 'type', 'category', 'frequency', + 'dataSource', 'source', 'libraryConfigurationId' + ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), + suite: source.suite == null ? null : clone(asObject(source.suite)), + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + }, 'practice light projection'); + } + + function projectLight(record) { + if (!record) return null; + return lightFromCanonical(canonicalizeRecord(record)); + } + + function firstNonEmpty(...values) { + let first; + for (const value of values) { + if (value === undefined || value === null) continue; + if (first === undefined) first = value; + if (Array.isArray(value) && value.length) return clone(value); + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value); + if (typeof value !== 'object') return clone(value); + } + return first === undefined ? {} : clone(first); + } + + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); + + function withoutRawData(value) { + if (Array.isArray(value)) return value.map(withoutRawData); + if (!value || typeof value !== 'object') return clone(value); + const clean = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item); + } + return clean; + } + + function splitPracticeRecord(input) { + const source = canonicalizeRecord(input); + const summary = lightFromCanonical(source); + const detail = { recordId: source.id }; + const annotations = { recordId: source.id }; + for (const [key, value] of Object.entries(source)) { + if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); + else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { + const next = Object.assign({}, asObject(entry)); + canonicalizeAnswerSource(next); + const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); + for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); + } + const annotation = {}; + for (const annotationKey of ANNOTATION_FIELDS) { + if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; } + if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey]; + if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey]; + } + delete next.realData; delete next.rawData; + if (Object.keys(annotation).length) { + if (!annotations.suiteEntries) annotations.suiteEntries = {}; + annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation; + } + return withoutRawData(next); + }); + else detail[key] = withoutRawData(value); + } + // Accept the old mirror only as an input normalization boundary; it is never persisted. + const realData = asObject(source.realData); const rawData = asObject(source.rawData); + for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); + } + for (const key of ANNOTATION_FIELDS) { + if (hasOwn(annotations, key)) continue; + if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]); + else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]); + } + return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') }; + } + + function joinPracticeRecord(summary, detail, annotations, projection = 'full') { + if (!summary) return null; + const mode = String(projection || 'full').toLowerCase(); + const light = clone(summary); + if (mode === 'light' || mode === 'summary') return light; + const joined = Object.assign({}, light, clone(asObject(detail))); + delete joined.recordId; + if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection'); + const annotationData = asObject(annotations); + for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value); + if (Array.isArray(joined.suiteEntries)) { + const suiteAnnotations = asObject(annotationData.suiteEntries); + joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {}))); + } + return jsonValue(joined, 'practice full projection'); + } + + function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); } + + // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。 + // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定, + // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败, + // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。 + const practiceRecordSource = global.PracticeRecordSource; + if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') { + throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)'); + } + const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord; + + function computeStats(records) { + const stats = defaultStats(); + for (const record of asArray(records).filter(isRealPracticeRecord)) { + const summary = projectLight(record); + const type = String(summary.type || '').toLowerCase(); + const target = type.includes('listen') ? stats.listening : stats.reading; + stats.totalPractices += 1; + stats.totalQuestions += summary.totalQuestions; + stats.correctAnswers += summary.correctAnswers; + target.practices += 1; + target.questions += summary.totalQuestions; + target.correct += summary.correctAnswers; + } + stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0; + for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0; + stats.lastUpdated = nowIso(); + return stats; + } + + function validIso(value) { + if (value === null || value === undefined || value === '') return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function practiceType(record) { + const metadata = asObject(record.metadata); + const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType, + record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase(); + if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening'; + if (hints.includes('read')) return 'reading'; + return null; + } + + function accuracyRatio(record) { + const summary = lightFromCanonical(record); + const value = Number(summary.accuracy); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } + + function durationSeconds(record) { + const scoreInfo = asObject(record.scoreInfo); + const realData = asObject(record.realData); + const realScoreInfo = asObject(realData.scoreInfo); + for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; + } + return 0; + } + + function earlierUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function laterUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function computeAchievementProgress(records, manual, existing) { + const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord) + .map((record, index) => ({ + record, + index, + unlockedAt: validIso(record.completedAt || record.timestamp), + time: new Date(record.completedAt || record.timestamp).getTime() + })) + .sort((left, right) => { + const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER; + const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER; + return leftTime - rightTime || left.index - right.index; + }); + const candidates = {}; + const setThreshold = (id, list, count) => { + if (list.length >= count) candidates[id] = list[count - 1].unlockedAt; + }; + setThreshold('first_step', items, 1); + setThreshold('practice_bronze', items, 10); + setThreshold('practice_silver', items, 50); + setThreshold('practice_gold', items, 100); + setThreshold('practice_platinum', items, 200); + + const reading = items.filter((item) => practiceType(item.record) === 'reading'); + const listening = items.filter((item) => practiceType(item.record) === 'listening'); + setThreshold('reading_first', reading, 1); + setThreshold('reading_bronze', reading, 10); + setThreshold('reading_silver', reading, 50); + setThreshold('reading_gold', reading, 100); + setThreshold('listening_first', listening, 1); + setThreshold('listening_bronze', listening, 10); + setThreshold('listening_silver', listening, 50); + setThreshold('listening_gold', listening, 100); + if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt); + if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt); + + let cumulativeDuration = 0; + let cumulativeAccuracy = 0; + let perfectCount = 0; + let speedCount = 0; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const accuracy = accuracyRatio(item.record); + const duration = durationSeconds(item.record); + cumulativeDuration += duration; + cumulativeAccuracy += accuracy; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt; + if (accuracy >= 1) { + perfectCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt; + if (perfectCount === 3) candidates.perfect_three = item.unlockedAt; + if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt; + } + if (duration > 0 && duration <= 300 && accuracy > 0.8) { + speedCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt; + if (speedCount === 3) candidates.speed_three = item.unlockedAt; + if (speedCount === 10) candidates.speed_ten = item.unlockedAt; + } + } + + const dayItems = new Map(); + for (const item of items) { + if (!item.unlockedAt) continue; + const day = item.unlockedAt.slice(0, 10); + if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt); + } + const days = Array.from(dayItems.keys()).sort(); + let streak = 0; + let previousDay = null; + for (const day of days) { + const currentDay = new Date(`${day}T00:00:00.000Z`).getTime(); + streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1; + previousDay = currentDay; + if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day); + if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day); + if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day); + if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day); + } + + const progress = {}; + const mergeUnlocked = (source) => { + for (const [rawId, value] of Object.entries(asObject(source))) { + if (!value || rawId === 'updatedAt') continue; + const id = rawId; + const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null; + if (!progress[id]) progress[id] = { unlockedAt }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + }; + mergeUnlocked(existing); + mergeUnlocked(manual); + for (const [id, unlockedAt] of Object.entries(candidates)) { + if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + return jsonValue(progress, 'achievement progress'); + } + + // Entity records are authoritative. Projections are assembled on reads, never cached or + // scheduled as follow-up work; this keeps a successful write immediately observable. + async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } + + async function retryMergeConflict(options, task, maxAttempts = 3) { + const explicitRevision = hasOwn(options, 'expectedRevision'); + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) { + throw error; + } + } + } + throw lastError; + } + + async function readCollectionMeta(logicalKey) { + const meta = await kernel.read(logicalKey, { withMeta: true }); + return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 }; + } + + function retainBackupEntries(items, limit = 20, preserveIds = []) { + const cap = Math.max(1, Number(limit) || 20); + const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || '')); + const entries = asArray(items).filter(Boolean).sort(newestFirst); + const retained = []; + const retainedIds = new Set(); + const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean)); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap) break; + if (retainedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + return retained; + } + + function hasOwn(value, key) { + return Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); + } + + function normalizeLibraryConfigurationId(value) { + return importedLibraryId(value, { nullable: true }); + } + + async function practiceRecordWithLibraryProvenance(source, command, options = {}) { + assertObject(source, 'practice record must be an object'); + const record = jsonValue(source, 'practice record'); + const metadata = asObject(record.metadata); + let configurationId; + + if (hasOwn(command, 'libraryConfigurationId')) { + configurationId = command.libraryConfigurationId; + } else if (hasOwn(metadata, 'libraryConfigurationId')) { + configurationId = metadata.libraryConfigurationId; + } else if (hasOwn(record, 'libraryConfigurationId')) { + configurationId = record.libraryConfigurationId; + } else { + configurationId = await kernel.read('library.activeConfigurationId'); + } + + const normalizedId = normalizeLibraryConfigurationId(configurationId); + record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId }); + + if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) { + record.suiteEntries = record.suiteEntries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const next = jsonValue(entry, 'practice suite entry'); + const entryMetadata = asObject(next.metadata); + const entryId = hasOwn(entryMetadata, 'libraryConfigurationId') + ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId) + : normalizedId; + next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId }); + return next; + }); + } + + return record; + } + + function practiceRecordMatches(record, identities) { + const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean)); + if (!expected.size || !record || typeof record !== 'object') return false; + return ['id', 'recordId', 'sessionId'].some((field) => { + const value = record[field]; + return value !== undefined && value !== null && expected.has(String(value)); + }); + } + + function practiceLayerId(row) { + return String(row && (row.recordId || row.id || row.sessionId) || ''); + } + async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { + // The real kernel reads all three entity stores in one readonly + // IndexedDB transaction. Keep the fallback for deliberately minimal + // embedders and unit-test kernels that only expose the original methods. + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); + } + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + const summaries = ids === null + ? await kernel.listEntities('practiceSummaries', { withMeta }) + : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); + const targetIds = summaries.map(practiceLayerId).filter(Boolean); + const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) + .then((rows) => rows.filter(Boolean)); + return { + practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], + practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], + practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] + }; + } + async function practiceLayers(recordId, withMeta = false) { + const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + return { + summary: find('practiceSummaries'), + detail: find('practiceDetails'), + annotations: find('practiceAnnotations') + }; + } + async function suiteChildRecordIds(command, aggregateRecordId) { + const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); + const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); + if (sessionIds.size) { + const summaries = await kernel.listEntities('practiceSummaries'); + for (const summary of summaries) { + if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); + } + } + ids.delete(String(aggregateRecordId)); + return ids; + } + function entityRevision(row) { return row ? Number(row.revision) : 0; } + function practiceUpserts(recordId, layers, existing = {}) { + return [ + { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) }, + { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) }, + { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) } + ]; + } + async function joinedPractice(recordId, projection, snapshot = null) { + const mode = String(projection || 'full').toLowerCase(); + const layers = snapshot || await practiceProjectionSnapshot([recordId], false, + mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : null)); + const summary = asArray(layers.practiceSummaries) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (!summary) return null; + if (mode === 'light' || mode === 'summary') return clone(summary); + const detail = asArray(layers.practiceDetails) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); + const annotations = asArray(layers.practiceAnnotations) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + return joinPracticeRecord(summary, detail, annotations, mode); + } + function practiceSummaryTime(summary) { + const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); + return Number.isFinite(time) ? time : 0; + } + function isReadingInsightSummary(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => practiceType(entry) === 'reading'); + } + return practiceType(asObject(summary)) === 'reading'; + } + function needsQuestionTypeInsightBackfill(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => + practiceType(entry) === 'reading' + && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); + } + return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + } + const practice = Object.freeze({ + async list(options = {}) { + await ready; + const projection = String(options.projection || 'full').toLowerCase(); + const snapshot = await practiceProjectionSnapshot(null, false, + projection === 'light' || projection === 'summary' + ? ['practiceSummaries'] + : null); + const summaries = asArray(snapshot.practiceSummaries); + if (projection === 'light' || projection === 'summary') return summaries; + return (await Promise.all(summaries + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) + .filter(Boolean); + }, + async listInsights(options = {}) { + await ready; + const requestedLimit = Number(options.limit); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 50) + : 10; + const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); + const summaries = asArray(snapshot.practiceSummaries) + .filter(isReadingInsightSummary) + .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) + .slice(0, limit); + return summaries.map((summary) => { + if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); + const detail = asArray(snapshot.practiceDetails) + .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; + return detail + ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) + : clone(summary); + }); + }, + async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, + async completeAttempt(command) { + await ready; + const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command; + const mutation = mutationOptions(command, 'practice-complete', source); + const recordInput = await practiceRecordWithLibraryProvenance(source, command); + if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId); + const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities( + practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation)); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async finalizeSuite(command) { + await ready; assertObject(command, 'finalizeSuite command is required'); + const mutation = mutationOptions(command, 'practice-suite', command); + const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); + if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); + const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command, async () => { + const existing = await practiceLayers(recordId, true); + const children = await suiteChildRecordIds(command, recordId); + const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); + return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); + }); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async updateAnnotations(command) { + await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || ''); + return retryMergeConflict(command, async () => { + const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`); + if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`); + const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data))); + const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default'); + if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) { + if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`); + annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) }); + } else { + if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`); + annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) }); + Object.assign(annotations, clone(asObject(command.patch))); + } + return kernel.mutateEntities([{ + type: 'upsert', + store: 'practiceAnnotations', + recordId, + data: annotations, + expectedRevision: entityRevision(current.annotations) + }], mutationOptions(command, 'practice-annotations', command)); + }); + }, + async delete(command) { + await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required'); + const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId })); + return Object.assign({}, receipt, { deletedCount: 1 }); + }, + async deleteMany(command) { + await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean))); + if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id]))); + if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); + }, + async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, + projectLight, + projectDetail + }); + + const settings = Object.freeze({ + async getAll() { await ready; return kernel.read('settings.values'); }, + async patch(values, options = {}) { + await ready; assertObject(values, 'settings.patch requires an object'); + const mutation = optionsMutationOptions(options, 'settings-patch', values); + return retryMergeConflict(options, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + }); + }, + async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); } + }); + + const library = Object.freeze({ + async listConfigurations() { await ready; return kernel.read('library.configurations'); }, + async getActive() { await ready; return kernel.read('library.activeConfigurationId'); }, + async getIndex(configurationId) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + if (id === null) return []; + const indexes = await kernel.read('library.importedIndexes'); + return asArray(indexes[id]); + }, + async updateConfiguration(configuration, options = {}) { + await ready; assertObject(configuration, 'library.updateConfiguration requires an object'); + const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId'])); + const current = await kernel.read('library.configurations', { withMeta: true }); + const configs = asArray(current.data); + const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id); + const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id }); + if (index >= 0) configs[index] = next; else configs.push(next); + return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration)); + }, + async activate(configurationId, options = {}) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + const current = await kernel.read('library.activeConfigurationId', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id })); + }, + async import(command) { + await ready; assertObject(command, 'library.import requires a command'); + const id = importedLibraryId(command.id || command.configurationId || randomId('library')); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id); + configs.push(Object.assign({}, asObject(command.configuration), { id, key: id })); + const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) }); + return kernel.mutate([ + { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ], mutationOptions(command, 'library-import', command)); + }, + async remove(configurationId, options = {}) { + await ready; const id = importedLibraryId(configurationId); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true }); + const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id]; + const changes = [ + { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ]; + if (String(activeMeta.data || '') === id) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 }); + } + return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id })); + }, + async resolveIndex() { + await ready; + const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]); + return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]); + } + }); + + function recoveryKey(kind) { + const key = RECOVERY_KEYS[String(kind || '')]; + if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`); + return key; + } + // Recovery document TTL is an AppData domain rule, not a catalog policy field. + const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000; + function recoveryTimestamp(item) { + for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) { + const parsed = Date.parse(item && item[field]); + if (Number.isFinite(parsed)) return parsed; + } + return null; + } + async function pruneRecoveryKey(logicalKey) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await kernel.read(logicalKey, { withMeta: true }); + const items = asArray(current.data); + const cutoff = Date.now() - RECOVERY_TTL_MS; + const retained = items.filter((item) => { + const timestamp = recoveryTimestamp(item); + return timestamp === null || timestamp > cutoff; + }); + if (retained.length === items.length) return items; + try { + await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], { + operationId: randomId('recovery-ttl') + }); + return retained; + } catch (error) { + if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error; + } + } + return kernel.read(logicalKey); + } + async function cleanupExpiredRecovery() { + for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey); + } + const windowSession = Object.freeze({ + save(name, value) { + if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable'); + const logicalName = String(name || 'default'); + const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) }; + global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload)); + return true; + }, + get(name) { + if (!global.sessionStorage) return null; + const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + if (!raw) return null; + const payload = JSON.parse(raw); + return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null; + }, + discard(name) { + if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + return true; + } + }); + + const recoveryMutationTails = new Map(); + function enqueueRecoveryMutation(logicalKey, task) { + const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve(); + const result = previous.then(task, task); + recoveryMutationTails.set(logicalKey, result.catch(() => undefined)); + return result; + } + + async function readRecovery(kind, id) { + await ready; + const items = await pruneRecoveryKey(recoveryKey(kind)); + return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null; + } + async function saveRecovery(kind, value, options = {}) { + await ready; assertObject(value, `recovery ${kind} value must be an object`); + const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value); + const key = recoveryKey(kind); + const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId); + const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() }); + const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + if (index >= 0) current.items[index] = item; else current.items.push(item); + return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation); + })); + const committedItem = (await kernel.read(key)) + .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + return Object.assign({}, receipt, { item: clone(committedItem || item) }); + } + async function discardRecovery(kind, id, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id)); + return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation); + })); + } + async function clearRecovery(kind, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) { + throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision }); + } + return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation); + })); + } + async function clearAllRecovery(options = {}) { + const results = {}; + for (const kind of Object.keys(RECOVERY_KEYS)) { + results[kind] = await clearRecovery(kind, options); + } + return results; + } + const recovery = Object.freeze({ + windowSession, + async clear(options = {}) { return clearAllRecovery(options); }, + async listActiveSessions() { return readRecovery('activeSession'); }, + async getActiveSession(id) { return readRecovery('activeSession', id); }, + async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); }, + async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async listDrafts() { return readRecovery('draft'); }, + async getDraft(id) { return readRecovery('draft', id); }, + async saveDraft(value, options) { return saveRecovery('draft', value, options); }, + async discardDraft(id, options) { return discardRecovery('draft', id, options); }, + async listInterrupted() { return readRecovery('interrupted'); }, + async getInterrupted(id) { return readRecovery('interrupted', id); }, + async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); }, + async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); }, + async listRejectedCompletions() { return readRecovery('rejectedCompletion'); }, + async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); }, + async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); }, + async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); } + }); + + function isImportableEntry(entry) { + return entry + && entry.classification !== 'system' + && entry.classification !== 'session' + && entry.import !== 'ignore'; + } + + function isPlainImportObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function isV2SnapshotShape(parsed) { + return isPlainImportObject(parsed) + && parsed.format === 'ielts-atlas-data-v2' + && isPlainImportObject(parsed.envelopes) + && isPlainImportObject(parsed.entities); + } + + const POISONED_V2_WRAPPER_ALIASES = Object.freeze({ + 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']), + 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']), + 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state']) + }); + const LIBRARY_IMPORT_KEYS = Object.freeze([ + 'library.configurations', + 'library.importedIndexes', + 'library.activeConfigurationId' + ]); + + function parseLegacyImportValue(value) { + if (typeof internals.parseLegacyValue !== 'function') { + throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); + } + return internals.parseLegacyValue(value); + } + + function decodePoisonedDocument(logicalKey, wrapped) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; + if (!aliases || !isPlainImportObject(wrapped) + || !Object.prototype.hasOwnProperty.call(wrapped, 'key') + || !Object.prototype.hasOwnProperty.call(wrapped, 'value') + || !aliases.includes(String(wrapped.key))) { + return { matched: false, value: null }; + } + const decoded = parseLegacyImportValue(wrapped.value); + if (!isPlainImportObject(decoded)) return { matched: true, value: null }; + const overlay = {}; + for (const [key, value] of Object.entries(wrapped)) { + if (key === 'key' || key === 'value' || key === 'timestamp') continue; + overlay[key] = clone(value); + } + return { + matched: true, + value: Object.assign({}, decoded, overlay) + }; + } + + function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { + if (!envelope || envelope.state !== 'present') return envelope; + const wrapped = envelope.data; + const decoded = decodePoisonedDocument(logicalKey, wrapped); + if (!decoded.matched || !decoded.value) return envelope; + const entry = catalog.get(logicalKey); + const next = internals.makeEnvelope(entry, decoded.value, { + revision: Number(envelope.revision) || 1, + operationId: String(envelope.operationId || randomId('import-repair')), + updatedAt: envelope.updatedAt + }); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + return next; + } + + function validateLibraryImportBundle(envelopes) { + const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (!presentKeys.length) return { valid: true, presentKeys }; + if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { + return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; + } + const values = {}; + for (const key of LIBRARY_IMPORT_KEYS) { + const envelope = envelopes[key]; + values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; + } + const configurations = asArray(values['library.configurations']); + const indexes = asObject(values['library.importedIndexes']); + const configurationIds = new Set(); + for (const configuration of configurations) { + const source = asObject(configuration); + const id = idOf(source, ['id', 'key', 'configId']); + if (!id || !acceptedLibraryId(id) || source.builtIn === true + || !Array.isArray(indexes[id]) || !indexes[id].length) { + return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; + } + configurationIds.add(id); + } + for (const [id, index] of Object.entries(indexes)) { + if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { + return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; + } + } + const activeId = values['library.activeConfigurationId']; + if (activeId !== null && (!acceptedLibraryId(activeId) + || !configurationIds.has(String(activeId)) + || !Array.isArray(indexes[String(activeId)]) + || !indexes[String(activeId)].length)) { + return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; + } + return { valid: true, presentKeys }; + } + + function canonicalizeV2Import(parsed) { + const warnings = []; + const repairedKeys = []; + const ignoredKeys = []; + const envelopes = {}; + for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + if (logicalKey === 'library.activeConfigurationId' + && rawEnvelope && rawEnvelope.state === 'present' + && String(rawEnvelope.data) === '[object Object]') { + ignoredKeys.push(logicalKey); + warnings.push('Skipped poisoned active library id'); + continue; + } + const repairCount = repairedKeys.length; + const repaired = repairPoisonedImportEnvelope( + logicalKey, + clone(rawEnvelope), + repairedKeys, + warnings + ); + const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; + const isLegacyRowWrapper = isPlainImportObject(rawData) + && Object.prototype.hasOwnProperty.call(rawData, 'key') + && Object.prototype.hasOwnProperty.call(rawData, 'value') + && String(rawData.key || '').startsWith('exam_system_'); + if (isLegacyRowWrapper && repairedKeys.length === repairCount) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + envelopes[logicalKey] = repaired; + } + const library = parsed.scope === 'full' + ? validateLibraryImportBundle(envelopes) + : { valid: true, presentKeys: [] }; + if (!library.valid) { + for (const logicalKey of library.presentKeys) { + delete envelopes[logicalKey]; + ignoredKeys.push(logicalKey); + } + warnings.push(`Skipped unsafe library data: ${library.reason}`); + } + const exportableKeys = catalog.list() + .filter((entry) => entry.export === true && isImportableEntry(entry)) + .map((entry) => entry.logicalKey); + const missingKeys = parsed.scope === 'full' + ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) + : []; + if (parsed.scope === 'full' && missingKeys.length) { + warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); + } + return { + envelopes, + warnings, + repairedKeys, + ignoredKeys, + missingKeys, + declaredScope: parsed.scope, + effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, + trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length + ? 'trusted-full' + : 'degraded-partial' + }; + } + + function resolveImportReplaceFlags(options = {}) { + const source = asObject(options); + const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase(); + const replaceAll = source.replace === true; + return { + replaceDocuments: replaceAll, + // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge. + replacePractice: replaceAll || practiceMode === 'replace' + }; + } + + function pickFirstRecordArray(candidates) { + for (const candidate of asArray(candidates)) { + if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) { + return { source: candidate.source, records: candidate.records }; + } + } + return null; + } + + /** + * Historical v1 export shapes (opensource / pre-AppData-v2): + * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats } + * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? } + * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*) + * - bare array of records, or { records: [...] } + * Recognition only — no dual backend and no local store migration. + */ + function extractLegacyPracticeRecords(payload) { + const sources = []; + const add = (source, records) => { + if (Array.isArray(records) && records.some(isPlainImportObject)) { + sources.push({ source, records }); + } + }; + + if (Array.isArray(payload)) { + add('(root array)', payload); + } else if (isPlainImportObject(payload)) { + const preferred = pickFirstRecordArray([ + { source: 'practice_records', records: payload.practice_records }, + { source: 'practiceRecords', records: payload.practiceRecords }, + { source: 'records', records: payload.records } + ]); + if (preferred) add(preferred.source, preferred.records); + + const data = isPlainImportObject(payload.data) ? payload.data : null; + if (data) { + const nested = pickFirstRecordArray([ + { source: 'data.practice_records', records: data.practice_records }, + { source: 'data.practiceRecords', records: data.practiceRecords } + ]); + if (nested) add(nested.source, nested.records); + else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data); + else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data); + if (isPlainImportObject(data.exam_system_practice_records)) { + add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); + } + } + if (isPlainImportObject(payload.exam_system_practice_records)) { + add('exam_system_practice_records.data', payload.exam_system_practice_records.data); + } + } + + const seen = new Set(); + const records = []; + for (const entry of sources) { + for (const item of asArray(entry.records)) { + if (!isPlainImportObject(item)) continue; + const identity = idOf(item, ['id', 'recordId', 'sessionId']); + if (identity) { + if (seen.has(identity)) continue; + seen.add(identity); + } + records.push(item); + } + } + return { + records, + sources: sources.map((entry) => entry.source) + }; + } + + function entityRowFromLayer(recordId, data, operationId) { + const payload = jsonValue(data, 'import practice entity'); + return { + recordId: String(recordId), + revision: 1, + operationId: String(operationId || `import-${recordId}`), + updatedAt: nowIso(), + data: payload, + checksum: checksum(payload) + }; + } + + function convertLegacyPracticeImport(payload) { + const extracted = extractLegacyPracticeRecords(payload); + if (!extracted.records.length) { + throw new AppDataError( + 'VALIDATION', + 'Import file is neither a v2 snapshot nor a recognizable v1 practice export' + ); + } + + const entities = { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + }; + const warnings = []; + let skipped = 0; + + for (const raw of extracted.records) { + try { + const layers = splitPracticeRecord(raw); + const recordId = layers.summary.id; + const operationId = `import-v1-${recordId}`; + entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId)); + entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId)); + entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId)); + } catch (error) { + skipped += 1; + warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`); + } + } + + if (!entities.practiceSummaries.length) { + throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized'); + } + + const accepted = entities.practiceSummaries.length; + return { + format: 'v1', + scope: 'partial', + envelopes: {}, + entities, + checksum: null, + warnings, + practiceSummary: { + accepted, + importedCount: accepted, + skippedCount: skipped, + sources: extracted.sources.slice() + } + }; + } + + function parseImportPayload(payload) { + let parsed; + try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); } + catch (error) { + if (error instanceof AppDataError) throw error; + throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message }); + } + + // Bare record arrays are a historical import convenience (UI file pickers). + if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed); + if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object'); + + if (isV2SnapshotShape(parsed)) { + if (Number(parsed.schemaVersion) !== Number(catalog.version)) { + throw new AppDataError('VALIDATION', 'Import schema version mismatch'); + } + if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) { + throw new AppDataError('VALIDATION', 'Import checksum mismatch'); + } + if (parsed.scope !== 'full' && parsed.scope !== 'partial') { + throw new AppDataError('VALIDATION', 'Import scope must be full or partial'); + } + const scope = parsed.scope; + for (const [store, rows] of Object.entries(parsed.entities)) { + if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) { + throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`); + } + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) { + throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + } + } + } + if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) { + throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer'); + } + const canonical = canonicalizeV2Import(parsed); + return { + format: 'v2', + scope: canonical.effectiveScope, + declaredScope: canonical.declaredScope, + envelopes: canonical.envelopes, + entities: parsed.entities, + checksum: parsed.checksum, + warnings: canonical.warnings, + practiceSummary: null, + repairedKeys: canonical.repairedKeys, + ignoredKeys: canonical.ignoredKeys, + missingKeys: canonical.missingKeys, + trust: canonical.trust + }; + } + + // Explicit but malformed v2 claims must not fall through to legacy parsers. + if (parsed.format === 'ielts-atlas-data-v2') { + throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported'); + } + + return convertLegacyPracticeImport(parsed); + } + + function collectionIdentityFields(logicalKey) { + if (logicalKey === 'library.configurations') return ['id', 'key', 'configId']; + if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId']; + if (logicalKey === 'backups.entries') return ['id']; + if (logicalKey === 'vocab.words') return ['id', 'word', 'key']; + if (logicalKey === 'goals.items') return ['id', 'goalId']; + return ['id', 'sessionId', 'recordId']; + } + + function collectionIdentity(logicalKey, value) { + const identity = idOf(value, collectionIdentityFields(logicalKey)); + return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity; + } + + function mergeCollection(existing, incoming, logicalKey) { + const result = asArray(existing).map((item) => clone(item)); + const positions = new Map(); + result.forEach((item, index) => { + const identity = collectionIdentity(logicalKey, item); + if (identity) positions.set(identity, index); + }); + for (const rawItem of asArray(incoming)) { + const item = jsonValue(rawItem, `${logicalKey} item`); + const identity = collectionIdentity(logicalKey, item); + if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`); + if (positions.has(identity)) result[positions.get(identity)] = item; + else { + positions.set(identity, result.length); + result.push(item); + } + } + return result; + } + + function mergeImportValue(entry, existing, incoming) { + const policy = entry.import; + if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey); + if (policy === 'patch') { + if (Array.isArray(existing) || Array.isArray(incoming)) { + // Array-shaped keys should use merge-by-id; treat accidental patch as replace. + return clone(incoming); + } + return Object.assign({}, asObject(existing), asObject(incoming)); + } + if (policy === 'replace') return clone(incoming); + throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`); + } + + async function currentEntitySnapshot() { + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(null, { withMeta: true }); + } + const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); + const result = {}; + for (const store of PRACTICE_ENTITY_STORES) { + if (store === 'practiceSummaries') result[store] = summaries; + else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean); + } + return result; + } + function practiceEntityIds(rows) { + return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean)); + } + function assertPracticeEntitySetsMatch(entities, message) { + const expected = practiceEntityIds(entities.practiceSummaries); + for (const store of PRACTICE_ENTITY_STORES.slice(1)) { + const actual = practiceEntityIds(entities[store]); + if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) { + throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', { + counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size])) + }); + } + } + } + async function createImportPlan(parsed, options = {}) { + const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options); + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} }; + const revisionToken = { documents: {}, entities: {} }; + const keys = []; const clearedKeys = []; + const warnings = asArray(parsed.warnings).map(String); + for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue; + if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`); + if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) { + warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); + continue; + } + const currentRead = await kernel.read(logicalKey, { withMeta: true }); + const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; + const currentEnvelope = currentRead && currentRead.envelope; + revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + let next = envelope; + if (!replaceDocuments && envelope.state === 'present') { + next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + } + snapshot.envelopes[logicalKey] = next; + keys.push(logicalKey); + if (next.state === 'cleared') clearedKeys.push(logicalKey); + } + + // Any successful practice import installs all three stores together. Merge + // may update a subset only when the final recordId sets remain identical. + const sourceStores = Object.keys(asObject(parsed.entities)); + let practiceExistingCount = null; + let practiceIncomingCount = null; + if (sourceStores.length) { + if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) { + throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations'); + } + const current = await currentEntitySnapshot(); + revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries( + asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0]) + )])); + practiceExistingCount = asArray(current.practiceSummaries).length; + practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length; + const existing = replacePractice + ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []])) + : current; + for (const store of PRACTICE_ENTITY_STORES) { + const rows = asArray(existing[store]).map(clone); + const positions = new Map(rows.map((row, index) => [String(row.recordId), index])); + for (const row of asArray(parsed.entities[store])) { + if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + const index = positions.get(String(row.recordId)); + if (index === undefined) { + positions.set(String(row.recordId), rows.length); + rows.push(clone(row)); + } else rows[index] = clone(row); + } + snapshot.entities[store] = rows; + } + assertPracticeEntitySetsMatch(snapshot.entities); + } + + snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities }); + const practiceSummary = parsed.practiceSummary + ? clone(parsed.practiceSummary) + : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries') + ? { + accepted: Number(practiceIncomingCount) || 0, + importedCount: Number(practiceIncomingCount) || 0, + skippedCount: 0, + existingCount: Number(practiceExistingCount) || 0, + incomingCount: Number(practiceIncomingCount) || 0, + finalCount: asArray(snapshot.entities.practiceSummaries).length, + removedCount: Math.max(0, (Number(practiceExistingCount) || 0) + - asArray(snapshot.entities.practiceSummaries).length) + } + : null); + if (practiceSummary && practiceSummary.existingCount === undefined) { + practiceSummary.existingCount = Number(practiceExistingCount) || 0; + practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0; + practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length; + practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount); + } + const destructive = clearedKeys.length > 0 + || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0); + return { + snapshot, + keys, + clearedKeys, + warnings, + practiceSummary, + destructive, + resetJournal: replaceDocuments && replacePractice, + revisionToken, + diagnostics: { + format: parsed.format, + replaceDocuments, + replacePractice, + declaredScope: parsed.declaredScope || parsed.scope, + effectiveScope: parsed.scope, + trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'), + missingKeys: clone(parsed.missingKeys || []), + repairedKeys: clone(parsed.repairedKeys || []), + ignoredKeys: clone(parsed.ignoredKeys || []) + } + }; + } + async function createRestoreSnapshot(backup) { + const parsed = parseImportPayload(asObject(backup && backup.data)); + if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups'); + if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch'); + return (await createImportPlan(parsed, { replace: true })).snapshot; + } + + const backups = Object.freeze({ + onDataCommitted(listener) { return kernel.onCommitted(listener); }, + async getSettings() { await ready; return kernel.read('backups.settings'); }, + async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); }, + async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); }, + async getImportHistory() { await ready; return kernel.read('backups.importHistory'); }, + async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); }, + async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); }, + async create(options = {}) { + await ready; const current = await readCollectionMeta('backups.entries'); + const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' }); + const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup')); + const existing = current.items.find((item) => String(item.id) === String(backupId)); + if (existing) { + if (String(existing.operationId || '') === String(mutation.operationId) + && String(existing.type || 'manual') === String(options.type || 'manual')) { + return clone(existing); + } + throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, { + backupId: String(backupId) + }); + } + const snapshot = await kernel.exportSnapshot(); + const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum }; + current.items.unshift(backup); + current.items = retainBackupEntries(current.items, 20, options.preserveIds); + await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation); + const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId)); + return clone(committed || backup); + }, + async list() { await ready; return kernel.read('backups.entries'); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); }, + async export(options = {}) { + await ready; + if (options.backupId !== undefined && options.backupId !== null) { + const backupId = String(options.backupId); + const stored = asArray(await kernel.read('backups.entries')) + .find((item) => String(item && item.id) === backupId); + if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`); + const portable = jsonValue(stored, 'stored backup export'); + if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) { + throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`); + } + return portable; + } + const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null; + const logicalKeys = domains + ? catalog.list() + .filter((entry) => domains.has(entry.owner) && entry.export === true) + .map((entry) => entry.logicalKey) + : null; + const entityStores = !domains || domains.has('practice') + ? undefined + : []; + return kernel.exportSnapshot(Object.assign( + logicalKeys ? { logicalKeys } : {}, + entityStores ? { entityStores } : {} + )); + }, + async previewImport(payload, options = {}) { + await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan'); + const cutoff = Date.now() - (30 * 60 * 1000); + for (const [id, existing] of importPlans) { + if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id); + } + const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) }; + importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive }; + }, + async commitImport(planId, options = {}) { + await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`); + if (plan.destructive && options.confirmDestructive !== true) { + throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation'); + } + const mutation = optionsMutationOptions(options, 'import-commit', { + planId: plan.id, + signature: plan.signature + }, { warnings: plan.warnings }); + const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, { + resetJournal: plan.resetJournal === true, + expectedRevisionToken: plan.revisionToken + })); + importPlans.delete(String(planId)); + return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) }); + }, + async restore(id, options = {}) { + await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id)); + if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`); + const snapshot = await createRestoreSnapshot(backup); + const restoreMutation = optionsMutationOptions(options, 'backup-restore', { + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }, { resetJournal: true }); + const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`; + const preRestoreBackupId = `pre_restore_${checksum({ + operationId: restoreMutation.operationId, + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }).replace(/[^a-z0-9]/gi, '')}`; + const preRestoreBackup = await backups.create({ + id: preRestoreBackupId, + operationId: preRestoreOperationId, + type: 'pre-restore', + preserveIds: [String(id)] + }); + const receipt = await kernel.installSnapshot(snapshot, restoreMutation); + return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id }); + } + }); + + let vocabMutationTail = Promise.resolve(); + function enqueueVocabMutation(task) { + const result = vocabMutationTail.then(task, task); + vocabMutationTail = result.catch(() => undefined); + return result; + } + function retryVocabMutation(options, task) { + return enqueueVocabMutation(() => retryMergeConflict(options, task)); + } + + const vocab = Object.freeze({ + async listWords() { await ready; return kernel.read('vocab.words'); }, + async saveWords(words, options = {}) { + await ready; assertArray(words, 'vocab.saveWords requires an array'); + const mutation = optionsMutationOptions(options, 'vocab-words', words); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.words', { withMeta: true }); + return mutateAndProject([{ + logicalKey: 'vocab.words', + data: words, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async getConfig() { await ready; return kernel.read('vocab.userConfig'); }, + async setConfig(config, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'vocab-config', config); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: asObject(config), + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async patchConfig(patch, options = {}) { + await ready; assertObject(patch, 'vocab.patchConfig requires an object'); + const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), clone(patch)); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); }, + async listCollections() { await ready; return kernel.read('vocab.lists'); }, + async saveCollection(id, value, options = {}) { + await ready; + const collectionId = String(id); + const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async saveCollections(values, options = {}) { + await ready; + assertObject(values, 'vocab.saveCollections requires an object'); + const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)])); + const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), upserts); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async upsertCollectionWord(collectionId, word, options = {}) { + await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word'); + const id = String(collectionId || ''); + if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required'); + const identity = String(word.word || word.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + const existing = collections[id]; + const list = existing && typeof existing === 'object' && !Array.isArray(existing) + ? Object.assign({}, clone(existing), { words: asArray(existing.words) }) + : { id, words: asArray(existing) }; + const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity); + const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() }); + if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt; + if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); + list.updatedAt = nowIso(); + collections[id] = list; + const receipt = await mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(nextWord) }); + }); + }, + async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; }, + async replaceListWords(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceListWords requires a command'); + const id = String(command.listId || 'default'); const words = asArray(command.words); + if (id === 'default') return this.saveWords(words, options); + const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async mergeListWords(command, options = {}) { + await ready; + assertObject(command, 'vocab.mergeListWords requires a command'); + const listId = String(command.listId || 'default'); + const incoming = asArray(command.words); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions(options, 'vocab-words-merge', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data)); + const storedList = listId === 'default' + ? asArray(current.data) + : (function readStoredCollection() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? asArray(collection.words) + : asArray(collection); + }()); + const merged = storedList.map((word) => clone(word)); + const positions = new Map(); + merged.forEach((word, index) => { + const identity = String(word && (word.word || word.id) || '').trim().toLowerCase(); + if (identity) positions.set(identity, index); + }); + let addedCount = 0; + let updatedCount = 0; + for (const rawWord of incoming) { + assertObject(rawWord, 'vocab.mergeListWords entries must be objects'); + const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + if (!positions.has(identity)) { + positions.set(identity, merged.length); + merged.push(clone(rawWord)); + addedCount += 1; + continue; + } + const index = positions.get(identity); + const existing = asObject(merged[index]); + const patch = {}; + if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim(); + if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim(); + if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq; + merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() }); + updatedCount += 1; + } + const data = listId === 'default' + ? merged + : Object.assign({}, collections, { + [listId]: Object.assign( + {}, + (function collectionBaseForWrite() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? clone(collection) + : {}; + }()), + { id: listId, words: merged, updatedAt: nowIso() } + ) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount }); + }); + }, + async patchWord(command, options = {}) { + await ready; assertObject(command, 'vocab.patchWord requires a command'); + const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || ''); + if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required'); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions( + Object.assign({}, options, { operationId: command.operationId || options.operationId }), + 'vocab-word-patch', + command + ); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : asObject(current.data); + const list = listId === 'default' + ? asArray(current.data) + : asArray(asObject(collections[listId]).words); + const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId); + if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`); + const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() }); + const next = list.slice(); next[index] = updated; + const data = listId === 'default' + ? next + : Object.assign({}, collections, { + [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(updated) }); + }); + }, + async replaceProgress(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceProgress requires a command'); + const listId = String(command.listId || 'default'); const words = asArray(command.words); + const mutation = optionsMutationOptions(options, 'vocab-progress', command); + return retryVocabMutation(options, async () => { + const configMeta = await kernel.read('vocab.userConfig', { withMeta: true }); + const changes = [{ + logicalKey: 'vocab.userConfig', + data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }), + expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0 + }]; + if (listId === 'default') { + const wordsMeta = await kernel.read('vocab.words', { withMeta: true }); + changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 }); + } else { + const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data)); + lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); + changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); + } + return mutateAndProject(changes, mutation); + }); + } + }); + + async function readPreferences() { await ready; return kernel.read('preferences.values'); } + let preferenceMutationTail = Promise.resolve(); + function enqueuePreferenceMutation(task) { + const result = preferenceMutationTail.then(task, task); + preferenceMutationTail = result.catch(() => undefined); + return result; + } + async function writePreference(field, value, options = {}) { + const mutation = optionsMutationOptions(options, 'preference-set', { field, value }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [field]: clone(value) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + async function patchPreference(field, patch, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const values = asObject(current.data); + const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + const preferences = Object.freeze({ + async getAll() { return readPreferences(); }, + async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); }, + async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); }, + async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); }, + async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); }, + async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); } + ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); }, + async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); }, + async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); }, + async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); }, + async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); }, + async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); }, + async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); }, + async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); } + }); + + const goals = Object.freeze({ + async list() { await ready; return kernel.read('goals.items'); }, + async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); } + }); + + function deliveryTimestamp(value) { + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN; + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function mergeDeliveryAcknowledgements(current, incoming) { + const merged = Object.assign({}, asObject(current)); + for (const [id, value] of Object.entries(asObject(incoming))) { + const key = String(id).trim(); + if (!key) continue; + const previous = deliveryTimestamp(merged[key]); + const next = deliveryTimestamp(value); + if (!hasOwn(merged, key) || (next && (!previous || next < previous))) { + merged[key] = next; + } else if (previous) { + merged[key] = previous; + } else { + merged[key] = null; + } + } + return merged; + } + + const achievements = Object.freeze({ + async getAll() { + await ready; + const progress = await retryMergeConflict({}, async () => { + const [summaries, manual, current] = await Promise.all([ + kernel.listEntities('practiceSummaries'), + kernel.read('achievements.manual'), + kernel.read('achievements.progress', { withMeta: true }) + ]); + const projected = asObject(computeAchievementProgress(summaries, manual, current.data)); + if (checksum(projected) !== checksum(asObject(current.data))) { + await kernel.mutate([{ + logicalKey: 'achievements.progress', + data: projected, + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], { + operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}` + }); + } + return projected; + }, 5); + if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh; + Object.defineProperty(progress, 'fresh', { value: true, enumerable: false }); + return progress; + }, + async retryPending() { return achievements.getAll(); }, + async acknowledgeDelivery(unlocked, options = {}) { + await ready; + assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object'); + const requested = clone(unlocked); + const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested); + return retryMergeConflict({}, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + const settingsValue = asObject(current.data); + const delivery = asObject(settingsValue.achievementDelivery); + const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested); + return kernel.mutate([{ + logicalKey: 'settings.values', + data: Object.assign({}, settingsValue, { + achievementDelivery: { version: 1, acknowledged } + }), + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], mutation); + }, 5); + }, + async getManualState() { await ready; return kernel.read('achievements.manual'); } + }); + + const LEGACY_DOCUMENT_ALIASES = Object.freeze({ + 'settings.values': ['user_settings', 'settings', 'system_settings'], + 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'], + 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'], + 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'], + 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'], + 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'], + 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], + 'achievements.manual': ['achievement_manual_state', 'user_achievements'] + }); + const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ + 'recovery.activeSessions', + 'recovery.drafts', + 'recovery.interrupted', + 'recovery.rejectedCompletions' + ]); + const LEGACY_PREFERENCE_ALIASES = Object.freeze({ + theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', + practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', + ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' + }); + + function legacyRecordArray(value) { + return Array.isArray(value) ? value : asArray(asObject(value).data); + } + function mergeLegacyExternalBackup(legacyValue, externalValue) { + const legacy = Object.assign({}, asObject(legacyValue)); + const external = asObject(externalValue); + const externalRecordKey = ['practice_records', 'practiceRecords'] + .find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (externalRecordKey) { + const records = new Map(); + for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { + const recordId = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); + } + legacy.practice_records = Array.from(records.values()); + } + for (const [target, aliases] of Object.entries({ + user_stats: ['user_stats', 'userStats'], + exam_index: ['exam_index', 'examIndex'], + storage_version: ['storage_version', 'storageVersion'] + })) { + if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (alias) legacy[target] = clone(external[alias]); + } + return legacy; + } + + function acceptedLibraryId(value) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; + try { return importedLibraryId(id); } catch (_) { return null; } + } + + function remapLegacyLibraryId(value) { + return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + } + + async function migrateLegacyLibraryData(legacy) { + const [configMeta, indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.configurations', { withMeta: true }), + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const legacyIdMap = new Map(); + const indexes = {}; + const addLegacyIndex = (oldId, value) => { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; + const index = asArray(value); + if (!index.length) return; + const mappedId = remapLegacyLibraryId(oldId); + legacyIdMap.set(oldId, mappedId); + indexes[mappedId] = clone(index); + }; + + for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); + // Reconciliation is a union. A healthy current v2 value wins for the same + // deterministic library ID, while missing legacy libraries are restored. + for (const [id, value] of Object.entries(asObject(indexMeta.data))) { + if (/^exam_index_/.test(id)) addLegacyIndex(id, value); + else { + const acceptedId = acceptedLibraryId(id); + if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); + } + } + + const configurations = new Map(); + const addConfiguration = (configuration) => { + const source = asObject(configuration); + const oldId = idOf(source, ['id', 'key', 'configId']); + if (!oldId || oldId === 'exam_index') return; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + if (!id || !asArray(indexes[id]).length) return; + configurations.set(id, Object.assign({}, clone(source), { + id, + key: id, + examCount: indexes[id].length + })); + }; + asArray(legacy.exam_index_configurations).forEach(addConfiguration); + asArray(configMeta.data).forEach(addConfiguration); + for (const [oldId, id] of legacyIdMap) { + if (!configurations.has(id)) { + configurations.set(id, { + id, + key: id, + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' + }); + } + } + + const resolveActive = (value) => { + const oldId = value === null || value === undefined ? '' : String(value).trim(); + if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + return id && asArray(indexes[id]).length ? id : null; + }; + const currentRawActive = activeMeta.data; + let activeId = resolveActive(currentRawActive); + const currentIsExplicitDefault = Boolean(activeMeta.envelope) + && (currentRawActive === null || String(currentRawActive || '').trim() === ''); + if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { + activeId = resolveActive(legacy.active_exam_index_key); + } + + const nextConfigurations = Array.from(configurations.values()); + const changes = []; + if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { + changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); + } + if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { + changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); + } + if (activeId !== activeMeta.data) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); + } + if (changes.length) { + await kernel.mutate(changes, { + operationId: `legacy-library-repair-v2-${checksum(changes)}` + }); + } + } + + async function migrateLegacyData() { + // Unit embedders may provide a deliberately minimal kernel bootstrap. + if (typeof internals.readLegacyValues !== 'function') return; + const legacySource = await internals.readLegacyValues(); + if (legacySource && legacySource.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); + const migrationState = asObject(migrationMeta.data); + let externalBackup = null; + if (asObject(migrationState.externalBackupV1).status !== 'consumed' + && typeof internals.readLegacyExternalBackup === 'function') { + try { + externalBackup = await internals.readLegacyExternalBackup(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); + } + } + const legacy = externalBackup + ? mergeLegacyExternalBackup(legacySource, externalBackup) + : legacySource; + if (!legacy || !Object.keys(legacy).length) return; + + const documentMetas = {}; + for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { + documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); + } + const [indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const documentRepairs = []; + const poisonedDocumentKeys = []; + for (const [logicalKey, current] of Object.entries(documentMetas)) { + if (!current.envelope || current.envelope.state !== 'present') continue; + const decoded = decodePoisonedDocument(logicalKey, current.data); + if (!decoded.matched) continue; + poisonedDocumentKeys.push(logicalKey); + const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; + const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + const legacyValue = legacyAlias ? legacy[legacyAlias] : null; + let repairValue = decoded.value; + if (isPlainImportObject(legacyValue)) { + repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); + } + if (!repairValue) continue; + documentRepairs.push({ + logicalKey, + data: repairValue, + expectedRevision: Number(current.envelope.revision) + }); + } + const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => + isPlainImportObject(value) + && Object.prototype.hasOwnProperty.call(value, 'key') + && Object.prototype.hasOwnProperty.call(value, 'value') + && /^exam_system_exam_index_/.test(String(value.key || ''))); + const poisonedActive = String(activeMeta.data) === '[object Object]'; + const libraryPoisoned = poisonedIndex || poisonedActive; + const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; + + await migrateLegacyLibraryData(legacy); + if (documentRepairs.length) { + await kernel.mutate(documentRepairs, { + operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` + }); + } + + const changes = []; + for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { + const currentAudit = asObject(migrationState.v1ToV2); + if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { + continue; + } + const current = await kernel.getEnvelope(logicalKey); + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + if (!alias) continue; + const legacyValue = legacy[alias]; + if (!current) { + changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); + continue; + } + const isBadMigrationWrite = current.state === 'present' + && /^legacy-documents-/.test(String(current.operationId || '')); + if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { + changes.push({ + logicalKey, + data: legacyValue, + expectedRevision: Number(current.revision) + }); + continue; + } + const entry = catalog.get(logicalKey); + if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; + let merged; + try { + merged = mergeImportValue(entry, legacyValue, current.data); + } catch (error) { + if (global.console && console.warn) { + console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); + } + continue; + } + if (checksum(merged) !== checksum(current.data)) { + changes.push({ + logicalKey, + data: merged, + expectedRevision: Number(current.revision) + }); + } + } + if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { + const preferences = {}; + for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) { + if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue; + const path = target.split('.'); let cursor = preferences; + path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; }); + cursor[path[path.length - 1]] = clone(legacy[alias]); + } + if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 }); + } + if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { + changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); + } + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + const recordsValue = legacy.practice_records; + const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); + const operations = []; + let skippedRecords = 0; + const reconciledRecordIds = new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + let canonical; + let parts; + try { + const candidate = jsonValue(record, 'legacy practice record'); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { + candidate.id = `legacy_${index}_${internals.checksum(record)}`; + } + canonical = canonicalizeRecord(candidate); + parts = splitPracticeRecord(canonical); + } catch (error) { + skippedRecords += 1; + if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); + continue; + } + if (reconciledRecordIds.has(canonical.id)) continue; + reconciledRecordIds.add(canonical.id); + // Storage errors are not malformed records. Let them abort this repair so the + // completion marker is not written and the next startup can retry safely. + const existing = await practiceLayers(canonical.id, true); + if (existing.summary && existing.detail && existing.annotations) continue; + operations.push(...practiceUpserts(canonical.id, parts, existing)); + } + if (operations.length) { + await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + } + const migrationAudit = { + version: 4, + status: 'complete', + mode: 'persistent-reconcile', + sourceChecksum: checksum(legacy), + sourceRecordCount: records.length, + skippedRecordCount: skippedRecords + }; + const currentAudit = asObject(migrationState.v1ToV2); + const comparableCurrentAudit = { + version: currentAudit.version, + status: currentAudit.status, + mode: currentAudit.mode, + sourceChecksum: currentAudit.sourceChecksum, + sourceRecordCount: currentAudit.sourceRecordCount, + skippedRecordCount: currentAudit.skippedRecordCount + }; + const externalAudit = externalBackup ? { + version: 1, + status: 'consumed', + sourceChecksum: checksum(externalBackup) + } : null; + const currentExternalAudit = asObject(migrationState.externalBackupV1); + if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) + || (externalAudit && (currentExternalAudit.status !== externalAudit.status + || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { + const nextMigrationState = Object.assign({}, migrationState, { + v1ToV2: Object.assign({}, migrationAudit, { + completedAt: nowIso(), + poisonDetected, + poisonedDocumentKeys, + libraryPoisoned + }) + }); + if (externalAudit) { + nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); + } + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { + operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` + }); + } + } + + const ready = kernel.initialize() + .then(async () => { + // Legacy migration and recovery cleanup are best-effort: a failure here + // (e.g. one malformed v1 record) must not brick the data layer for every + // read that awaits `ready`. Only a genuine backend init failure below is fatal. + try { + await migrateLegacyData(); + } catch (error) { + if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error); + } + try { + await cleanupExpiredRecovery(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error); + } + return true; + }) + .catch((error) => { + if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error); + throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed'); + }); + + const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements }; + Object.defineProperties(AppData, { + ready: { value: ready, enumerable: false }, + status: { value: () => kernel.status(), enumerable: false } + }); + Object.freeze(AppData); + Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false }); + if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) { + throw new Error('AppData v2 failed to close its internal bootstrap channel'); + } + if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) { + throw new Error('AppData v2 failed to close its catalog bootstrap channel'); + } +})(typeof window !== 'undefined' ? window : globalThis); + + /* ===== js/utils/practiceTimerPreferences.js ===== */ (function initPracticeTimerPreferences(global) { 'use strict'; - var READING_KEY = 'ielts_reading_timer_preferences_v2'; - var LISTENING_KEY = 'ielts_listening_timer_preferences_v1'; var VERSION = 1; var DEFAULTS = { version: VERSION, @@ -42,26 +4025,38 @@ }; } - function keyFor(scope) { - return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY; + var cache = Object.create(null); + var hydrationPromise = null; + function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; } + function hydrateTimerPreferences() { + if (cache.reading && cache.listening) return Promise.resolve(true); + if (hydrationPromise) return hydrationPromise; + if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false); + hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getTimer(); + cache.reading = normalize(stored && stored.reading); + cache.listening = normalize(stored && stored.listening); + return true; + }).catch(function onTimerPreferenceLoadError(error) { + hydrationPromise = null; + console.warn('[PracticeTimerPreferences] 加载失败:', error); + return false; + }); + return hydrationPromise; } function read(scope) { - try { - var raw = global.localStorage && global.localStorage.getItem(keyFor(scope)); - return normalize(raw ? JSON.parse(raw) : null); - } catch (_) { - return normalize(null); - } + return normalize(cache[normalizeScope(scope)]); } - function save(scope, preferences) { + async function save(scope, preferences) { + await hydrateTimerPreferences(); + if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable'); + var normalizedScope = normalizeScope(scope); var next = normalize(preferences); - try { - if (global.localStorage) { - global.localStorage.setItem(keyFor(scope), JSON.stringify(next)); - } - } catch (_) { } + await global.AppData.preferences.setTimer(normalizedScope, next); + cache[normalizedScope] = next; return next; } @@ -69,17 +4064,16 @@ return clampMinutes(value, DEFAULTS.countdownMinutes) * 60; } - global.PracticeTimerPreferences = { + var api = { VERSION: VERSION, - READING_KEY: READING_KEY, - LISTENING_KEY: LISTENING_KEY, DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)), normalize: normalize, read: read, save: save, - keyFor: keyFor, minutesToSeconds: minutesToSeconds }; + Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences }); + global.PracticeTimerPreferences = api; })(typeof window !== 'undefined' ? window : globalThis); @@ -90,8 +4084,8 @@ var BRIDGE_SCRIPT_URL = '/js/bundles/listening-record-bridge.bundle.js'; var ADAPTER_STYLE_ID = 'listening-unified-wrapper-adapter-style'; var TIMER_INTERVAL_MS = 1000; - var CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1'; var CANDIDATE_CODE_PATTERN = /^\d{6}$/; + var candidateCodeCache = { mode: 'auto', customCode: '' }; var state = { examId: '', sourceUrl: '', @@ -105,7 +4099,11 @@ bridgeInjected: false, bridgeReady: false, pendingMessages: [], - parentWindow: null, + parentWindow: global.opener || (global.parent && global.parent !== global ? global.parent : null), + expectedParentOrigin: '', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', timerInterval: null, lastTimerText: '' }; @@ -113,9 +4111,18 @@ function sameOrigin() { return global.location && global.location.origin && global.location.origin !== 'null' ? global.location.origin - : '*'; + : (global.location && global.location.protocol === 'file:' ? '*' : ''); } + try { + if (global.document && global.document.referrer) { + var referrerUrl = new URL(global.document.referrer, global.location.href); + state.expectedParentOrigin = referrerUrl.origin && referrerUrl.origin !== 'null' + ? referrerUrl.origin + : ''; + } + } catch (_) { } + function normalizeSafeId(value, fallback) { var text = String(value || '').trim(); return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,180}$/.test(text) ? text : fallback; @@ -211,20 +4218,15 @@ } function readCandidateCodePreferences() { - try { - var raw = global.localStorage && global.localStorage.getItem(CANDIDATE_CODE_PREF_KEY); - var parsed = raw ? JSON.parse(raw) : null; - var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto'; - var customCode = parsed && typeof parsed.customCode === 'string' - ? parsed.customCode.replace(/\D/g, '').slice(0, 6) - : ''; - return { - mode: mode, - customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' - }; - } catch (_) { - return { mode: 'auto', customCode: '' }; - } + return Object.assign({}, candidateCodeCache); + } + + async function loadCandidateCodePreferences() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getCandidateCode(); + var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto'; + var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : ''; + candidateCodeCache = { mode: mode, customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' }; } function resolveCandidateCode() { @@ -916,7 +4918,9 @@ return; } try { - win.postMessage(message, sameOrigin()); + var targetOrigin = sameOrigin(); + if (!targetOrigin) throw new Error('iframe target origin unavailable'); + win.postMessage(message, targetOrigin); } catch (_) { state.pendingMessages.push(message); } @@ -955,35 +4959,74 @@ return; } try { - target.postMessage(message, sameOrigin()); + var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) return; + target.postMessage(message, targetOrigin); } catch (_) { } } - function handleParentMessage(message, source) { - if (source && source !== global && typeof source.postMessage === 'function') { - state.parentWindow = source; - } + function handleParentMessage(event) { + var message = event && event.data; + var source = event && event.source; var type = message && message.type; if (type === 'INIT_SESSION' || type === 'init_exam_session') { var payload = message.data || message; + var incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : ''; + var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : ''; + if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host' || !incomingToken) return; + if (state.expectedParentOrigin) { + if (incomingOrigin !== state.expectedParentOrigin || declaredOrigin !== state.expectedParentOrigin) return; + state.parentOrigin = state.expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else { + if (incomingOrigin !== 'null' || declaredOrigin !== 'null' || global.location.protocol !== 'file:') return; + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } + state.windowSessionToken = incomingToken; state.examId = normalizeSafeId(payload.examId, state.examId || 'listening-unknown'); state.sessionId = normalizeSafeId(payload.sessionId, state.sessionId || (state.examId + '_' + Date.now())); state.suiteSessionId = normalizeSafeId(payload.suiteSessionId, state.suiteSessionId || ''); state.startTime = Number.isFinite(Number(payload.startTime)) ? Number(payload.startTime) : state.startTime; + } else { + var messagePayload = message && message.data || {}; + var messageOrigin = typeof event.origin === 'string' ? event.origin : ''; + var messageToken = typeof messagePayload.windowSessionToken === 'string' ? messagePayload.windowSessionToken.trim() : ''; + var originMatches = state.parentOriginIsOpaque + ? messageOrigin === 'null' + : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin); + if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host' + || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) return; } forwardToIframe(message); } function handleMessage(event) { - if (!event || !event.data || (event.origin && event.origin !== global.location.origin)) { + if (!event || !event.data) { return; } var frameWindow = getFrameWindow(); if (event.source && frameWindow && event.source === frameWindow) { + var frameOrigin = sameOrigin(); + if (frameOrigin === '*') { + if (event.origin !== 'null') return; + } else if (!frameOrigin || event.origin !== frameOrigin) { + return; + } + var framePayload = event.data && event.data.data || {}; + var permitsPreInit = event.data.type === 'REQUEST_INIT' + || (event.data.type === 'SESSION_READY' && framePayload.initialized !== true); + if (!permitsPreInit && ( + !state.windowSessionToken + || framePayload.windowSessionToken !== state.windowSessionToken + )) return; forwardToParent(event.data); return; } - handleParentMessage(event.data, event.source); + handleParentMessage(event); } function exposeCompatibilityApi() { @@ -1023,7 +5066,9 @@ }; } - function init() { + async function init() { + await loadCandidateCodePreferences(); + if (global.PracticeTimerPreferences && global.PracticeTimerPreferences.ready) await global.PracticeTimerPreferences.ready; var root = getRoot(); var frame = getFrame(); if (!root || !frame) { @@ -1059,6 +5104,10 @@ (function markBundleProvided(global) { if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { global.AppLazyLoader.markProvided([ + "js/data/practiceRecordSource.js", + "js/data/v2/dataCatalog.js", + "js/data/v2/dataKernel.js", + "js/data/v2/appData.js", "js/utils/practiceTimerPreferences.js", "js/listeningUnifiedWrapper.js" ]); diff --git a/js/bundles/more.bundle.js b/js/bundles/more.bundle.js index 6cd40d44..8617f4a4 100644 --- a/js/bundles/more.bundle.js +++ b/js/bundles/more.bundle.js @@ -248,10 +248,12 @@ return buildImportResult('progress', entries, { format: 'json', originalLength: payload.words.length, + listId: typeof payload.listId === 'string' && payload.listId.trim() + ? payload.listId.trim() + : undefined, category: category || 'user', version: typeof payload.version === 'string' ? payload.version : undefined, config: payload.config && typeof payload.config === 'object' ? { ...payload.config } : undefined, - reviewQueue: Array.isArray(payload.reviewQueue) ? payload.reviewQueue.slice() : undefined, name: typeof payload.name === 'string' ? payload.name : undefined, source: typeof payload.source === 'string' ? payload.source : undefined, exportedAt: typeof payload.exportedAt === 'string' ? payload.exportedAt : undefined @@ -322,17 +324,17 @@ } async function exportProgress() { - const store = window.VocabStore; - if (!store || typeof store.init !== 'function') { - throw new Error('VocabStore 未加载'); - } - await store.init(); + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab 未加载'); + await window.AppData.ready; + const config = await window.AppData.vocab.getConfig(); + const listId = config.activeListId || 'default'; + const list = await window.AppData.vocab.readList(listId); const payload = { version: DEFAULT_EXPORT_VERSION, exportedAt: new Date().toISOString(), - config: store.getConfig(), - words: store.getWords(), - reviewQueue: store.getReviewQueue() + listId, + config, + words: Array.isArray(list) ? list : (list && Array.isArray(list.words) ? list.words : []) }; return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); } @@ -723,53 +725,40 @@ id: 'default', name: 'IELTS 核心词表', icon: '📚', - source: 'builtin', - storageKey: 'vocab_words' + source: 'builtin' }, 'spelling-errors-p1': { id: 'spelling-errors-p1', name: 'P1 拼写错误', icon: '📝', - source: 'p1', - storageKey: 'vocab_list_p1_errors' + source: 'p1' }, 'spelling-errors-p4': { id: 'spelling-errors-p4', name: 'P4 拼写错误', icon: '📝', - source: 'p4', - storageKey: 'vocab_list_p4_errors' + source: 'p4' }, 'spelling-errors-master': { id: 'spelling-errors-master', name: '综合错误词表', icon: '📚', - source: 'all', - storageKey: 'vocab_list_master_errors' + source: 'all' }, 'custom': { id: 'custom', name: '自定义词表', icon: '✏️', - source: 'user', - storageKey: 'vocab_list_custom' + source: 'user' }, 'reading-highlights': { id: 'reading-highlights', name: '阅读高亮生词', icon: '📖', - source: 'reading-highlight', - storageKey: 'vocab_list_reading_highlights' + source: 'reading-highlight' } }); - const STORAGE_KEYS = Object.freeze({ - WORDS: 'vocab_words', - CONFIG: 'vocab_user_config', - REVIEW_QUEUE: 'vocab_review_queue', - ACTIVE_LIST: 'vocab_active_list_id' - }); - const DEFAULT_CONFIG = Object.freeze({ dailyNew: 20, reviewLimit: 100, @@ -778,29 +767,31 @@ notify: true }); - const DEFAULT_REVIEW_QUEUE = Object.freeze([]); const DEFAULT_LIST_ID = 'default'; const DEFAULT_LEXICON_URL = 'assets/wordlists/ielts_core.json'; const SPELLING_ERROR_LIST_IDS = new Set(['spelling-errors-p1', 'spelling-errors-p4', 'spelling-errors-master']); const state = { - repositories: null, - metaRepo: null, - storageManager: null, words: [], wordIndex: new Map(), config: { ...DEFAULT_CONFIG }, - reviewQueue: DEFAULT_REVIEW_QUEUE.slice(), ready: false, readyPromise: null, readyResolvers: [], loadingPromise: null, - registryUnsubscribe: null, lastLoadSource: 'init', activeListId: DEFAULT_LIST_ID, listCache: new Map() }; + function cloneValue(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + function emitReady(value) { if (state.ready) { return; @@ -999,59 +990,30 @@ }); } - async function persist(key, value) { - try { - if (state.metaRepo && typeof state.metaRepo.set === 'function') { - await state.metaRepo.set(key, value, { clone: true }); - return true; - } - if (state.storageManager && typeof state.storageManager.set === 'function') { - await state.storageManager.set(key, value); - return true; - } - if (typeof localStorage !== 'undefined') { - localStorage.setItem(key, JSON.stringify(value)); - return true; - } - } catch (error) { - console.error('[VocabStore] persist error:', error); - } - return false; + async function requireVocabData() { + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; + return window.AppData.vocab; } - async function read(key, defaultValue) { - if (state.metaRepo && typeof state.metaRepo.get === 'function') { - try { - const value = await state.metaRepo.get(key, defaultValue); - if (value !== undefined) { - return value; - } - } catch (error) { - console.warn('[VocabStore] metaRepo读取失败:', error); - } - } - if (state.storageManager && typeof state.storageManager.get === 'function') { - try { - const value = await state.storageManager.get(key, defaultValue); - if (value !== undefined) { - return value; - } - } catch (error) { - console.warn('[VocabStore] storageManager读取失败:', error); - } - } - if (typeof localStorage !== 'undefined') { - try { - const raw = localStorage.getItem(key); - if (!raw) { - return defaultValue; - } - return JSON.parse(raw); - } catch (error) { - console.warn('[VocabStore] localStorage解析失败:', error); - } - } - return defaultValue; + async function readListData(listId) { + const vocab = await requireVocabData(); + if (listId === DEFAULT_LIST_ID) return vocab.listWords(); + const collections = await vocab.listCollections(); + return Object.prototype.hasOwnProperty.call(collections, listId) ? collections[listId] : null; + } + + async function saveListData(listId, value) { + const vocab = await requireVocabData(); + const words = value && typeof value === 'object' && Array.isArray(value.words) ? value.words : value; + await vocab.replaceListWords({ listId, words: Array.isArray(words) ? words : [] }); + return true; + } + + async function saveConfigData(configPatch = state.config) { + const vocab = await requireVocabData(); + await vocab.patchConfig(Object.assign({}, configPatch, { activeListId: state.activeListId })); + return true; } function mergeConfig(config) { @@ -1071,15 +1033,6 @@ rebuildIndex(); } - function getStorageKeyForListId(listId) { - const targetId = typeof listId === 'string' && VOCAB_LISTS[listId] ? listId : DEFAULT_LIST_ID; - return VOCAB_LISTS[targetId].storageKey; - } - - function getActiveStorageKey() { - return getStorageKeyForListId(state.activeListId); - } - function isSpellingErrorList(listId) { return SPELLING_ERROR_LIST_IDS.has(listId); } @@ -1193,29 +1146,26 @@ return state.loadingPromise; } state.loadingPromise = (async () => { - const [storedConfig, storedQueue, storedActiveList] = await Promise.all([ - read(STORAGE_KEYS.CONFIG, { ...DEFAULT_CONFIG }), - read(STORAGE_KEYS.REVIEW_QUEUE, DEFAULT_REVIEW_QUEUE.slice()), - read(STORAGE_KEYS.ACTIVE_LIST, DEFAULT_LIST_ID) - ]); + const vocab = await requireVocabData(); + const storedConfig = await vocab.getConfig(); + const storedActiveList = storedConfig && storedConfig.activeListId; state.activeListId = typeof storedActiveList === 'string' && VOCAB_LISTS[storedActiveList] ? storedActiveList : DEFAULT_LIST_ID; - const activeStorageKey = getStorageKeyForListId(state.activeListId); - const storedWords = await read(activeStorageKey, []); + const storedWords = await readListData(state.activeListId); const normalizedWords = normalizeStoredListWords(storedWords, state.activeListId); if (normalizedWords.length) { setWordsInternal(normalizedWords); - state.lastLoadSource = state.metaRepo ? 'meta' : (state.storageManager ? 'storage' : 'localStorage'); + state.lastLoadSource = 'appData-v2'; } state.config = mergeConfig(storedConfig); - state.reviewQueue = Array.isArray(storedQueue) ? storedQueue.map((id) => String(id)) : []; })() .catch((error) => { console.error('[VocabStore] 初始化加载失败:', error); + throw error; }) .finally(() => { state.loadingPromise = null; @@ -1225,8 +1175,7 @@ async function ensureDefaultLexicon() { try { - const defaultStorageKey = getStorageKeyForListId(DEFAULT_LIST_ID); - const storedDefault = await read(defaultStorageKey, []); + const storedDefault = await readListData(DEFAULT_LIST_ID); const normalizedStored = normalizeStoredListWords(storedDefault, DEFAULT_LIST_ID); const pollutedBySpellingList = isLikelySpellingErrorSnapshot(normalizedStored); if (normalizedStored.length && !pollutedBySpellingList) { @@ -1244,7 +1193,7 @@ console.warn('[VocabStore] 默认词库为空'); return []; } - await persist(defaultStorageKey, normalized); + await saveListData(DEFAULT_LIST_ID, normalized); if (state.activeListId === DEFAULT_LIST_ID) { setWordsInternal(normalized); state.lastLoadSource = 'default'; @@ -1266,8 +1215,8 @@ }); return normalized; } catch (error) { - console.warn('[VocabStore] 默认词库加载失败:', error); - return []; + console.error('[VocabStore] 默认词库加载失败:', error); + throw error; } } @@ -1277,87 +1226,39 @@ emitReady(true); } - function connectToProviders() { - if (state.registryUnsubscribe || state.repositories || state.storageManager) { - return; - } - const registry = window.StorageProviderRegistry; - if (registry && typeof registry.onProvidersReady === 'function') { - state.registryUnsubscribe = registry.onProvidersReady((payload) => { - if (payload && payload.repositories) { - attachRepositories(payload.repositories); - } - if (payload && payload.storageManager) { - state.storageManager = payload.storageManager; - } - }); - const current = typeof registry.getCurrentProviders === 'function' ? registry.getCurrentProviders() : null; - if (current) { - if (current.repositories) { - attachRepositories(current.repositories); - } - if (current.storageManager) { - state.storageManager = current.storageManager; - } - } - return; - } - if (window.dataRepositories) { - attachRepositories(window.dataRepositories); - } - if (window.storage) { - state.storageManager = window.storage; - } - } - - async function attachRepositories(repositories) { - if (!repositories || state.repositories === repositories) { - return; - } - state.repositories = repositories; - state.metaRepo = repositories.meta || null; - await loadState(); - if (!state.words.length) { - await ensureDefaultLexicon(); - } - await persist(getActiveStorageKey(), state.words); - await persist(STORAGE_KEYS.CONFIG, state.config); - await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue); - emitReady(true); - } - function getWords() { - return state.words.map((word) => ({ ...word })); + return cloneValue(state.words); } - async function setWords(words) { + async function mergeWords(words) { const normalized = Array.isArray(words) ? words.map((word) => normalizeWordRecord(word)).filter(Boolean) : []; - setWordsInternal(normalized); - await persist(getActiveStorageKey(), normalized); + const vocab = await requireVocabData(); + const receipt = await vocab.mergeListWords({ listId: state.activeListId, words: normalized }); + const committedWords = Array.isArray(receipt.words) ? receipt.words : []; + setWordsInternal(committedWords.map((word) => normalizeWordRecord(word)).filter(Boolean)); state.listCache.delete(state.activeListId); - return getWords(); + return { + words: getWords(), + addedCount: Number(receipt.addedCount) || 0, + updatedCount: Number(receipt.updatedCount) || 0 + }; } async function updateWord(id, patch = {}) { if (!id || !state.wordIndex.has(id)) { return null; } - const original = state.wordIndex.get(id); - const updated = normalizeWordRecord({ - ...original, - ...patch, - id, - updatedAt: getNow() - }); + const vocab = await requireVocabData(); + const receipt = await vocab.patchWord({ listId: state.activeListId, wordId: id, patch }); + const updated = normalizeWordRecord(receipt.word); const index = state.words.findIndex((word) => word.id === id); if (index >= 0 && updated) { state.words.splice(index, 1, updated); state.wordIndex.set(id, updated); - await persist(getActiveStorageKey(), state.words); state.listCache.delete(state.activeListId); - return { ...updated }; + return cloneValue(updated); } return null; } @@ -1367,19 +1268,29 @@ } async function setConfig(config) { - state.config = mergeConfig(config); - await persist(STORAGE_KEYS.CONFIG, state.config); + const next = mergeConfig(config); + await saveConfigData(next); + state.config = next; return getConfig(); } - function getReviewQueue() { - return state.reviewQueue.slice(); - } - - async function setReviewQueue(queue) { - state.reviewQueue = Array.isArray(queue) ? queue.map((id) => String(id)) : []; - await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue); - return getReviewQueue(); + async function replaceProgress(words, config = {}, listId = null) { + const normalized = Array.isArray(words) + ? words.map((word) => normalizeWordRecord(word)).filter(Boolean) + : []; + const requestedListId = typeof listId === 'string' && listId.trim() + ? listId.trim() + : (typeof config.activeListId === 'string' && config.activeListId.trim() + ? config.activeListId.trim() + : state.activeListId); + const nextConfig = mergeConfig({ ...config, activeListId: requestedListId }); + const vocab = await requireVocabData(); + await vocab.replaceProgress({ listId: requestedListId, words: normalized, config: nextConfig }); + state.config = nextConfig; + state.activeListId = requestedListId; + setWordsInternal(normalized); + state.listCache.delete(requestedListId); + return { words: getWords(), config: getConfig() }; } function getDueWords(referenceTime = new Date()) { @@ -1518,8 +1429,7 @@ } try { - const storageKey = listConfig.storageKey; - let storedData = await read(storageKey, null); + let storedData = await readListData(listId); if (listId === DEFAULT_LIST_ID && (!storedData || (Array.isArray(storedData) && storedData.length === 0))) { const ensured = await ensureDefaultLexicon(); storedData = ensured; @@ -1554,7 +1464,7 @@ return listData; } catch (error) { console.error('[VocabStore] loadList 失败:', error); - return null; + throw error; } } @@ -1579,26 +1489,12 @@ } try { - // 保存当前词表到存储(如果有修改) - if (state.activeListId && state.words.length > 0) { - const currentConfig = VOCAB_LISTS[state.activeListId]; - if (currentConfig) { - await persist(currentConfig.storageKey, state.words); - } - } - - // 切换到新词表 + const vocab = await requireVocabData(); + await vocab.activateList(listId); state.activeListId = listId; setWordsInternal(listData.words || []); state.listCache.delete(listId); - // 保存激活的词表 ID - await persist(STORAGE_KEYS.ACTIVE_LIST, listId); - - // 清空复习队列(新词表需要重新生成队列) - state.reviewQueue = []; - await persist(STORAGE_KEYS.REVIEW_QUEUE, []); - return true; } catch (error) { console.error('[VocabStore] setActiveList 失败:', error); @@ -1625,8 +1521,7 @@ // 从存储读取 try { - const listConfig = VOCAB_LISTS[listId]; - const storedData = await read(listConfig.storageKey, null); + const storedData = await readListData(listId); // 检查是否为拼写错误词表格式 if (storedData && typeof storedData === 'object' && Array.isArray(storedData.words)) { @@ -1638,7 +1533,7 @@ return 0; } catch (error) { console.error('[VocabStore] getListWordCount 失败:', error); - return 0; + throw error; } } @@ -1706,8 +1601,7 @@ } await init(); const listId = 'reading-highlights'; - const listConfig = VOCAB_LISTS[listId]; - const storedData = await read(listConfig.storageKey, []); + const storedData = await readListData(listId); const words = normalizeStoredListWords(storedData, listId); const key = normalized.word.toLowerCase(); const existingIndex = words.findIndex((entry) => String(entry.word || '').trim().toLowerCase() === key); @@ -1723,7 +1617,7 @@ } else { words.push(normalized); } - await persist(listConfig.storageKey, words.filter(Boolean)); + await saveListData(listId, words.filter(Boolean)); state.listCache.delete(listId); if (state.activeListId === listId) { setWordsInternal(words.filter(Boolean)); @@ -1733,7 +1627,6 @@ async function init() { ensureReadyPromise(); - connectToProviders(); if (!state.ready) { await bootstrap(); } @@ -1743,12 +1636,11 @@ const api = { init, getWords, - setWords, + mergeWords, updateWord, getConfig, setConfig, - getReviewQueue, - setReviewQueue, + replaceProgress, getDueWords, getNewWords, loadList, @@ -3141,17 +3033,15 @@ ? meta.name.trim() : (typeof meta.source === 'string' && meta.source.trim() ? meta.source.trim() : ''); if (result.type === 'progress') { - await state.store.setWords(entries); - if (meta.config && typeof meta.config === 'object') { - await state.store.setConfig(meta.config); - const latestConfig = state.store.getConfig(); - const limit = Number(latestConfig?.reviewLimit); - if (Number.isFinite(limit) && limit > 0) { - state.session.batchSize = Math.max(1, Math.min(limit, DEFAULT_BATCH_SIZE)); - } - } - if (Array.isArray(meta.reviewQueue)) { - await state.store.setReviewQueue(meta.reviewQueue); + await state.store.replaceProgress( + entries, + meta.config && typeof meta.config === 'object' ? meta.config : {}, + typeof meta.listId === 'string' ? meta.listId : null + ); + const latestConfig = state.store.getConfig(); + const limit = Number(latestConfig?.reviewLimit); + if (Number.isFinite(limit) && limit > 0) { + state.session.batchSize = Math.max(1, Math.min(limit, DEFAULT_BATCH_SIZE)); } resetSessionState(); prepareSessionQueue(); @@ -3166,47 +3056,13 @@ showFeedbackMessage(`${categoryLabel}${suffix}导入完成,已同步 ${entries.length} 条词汇`, 'success'); return; } - const existing = state.store.getWords(); - const merged = existing.slice(); - const indexByWord = new Map(); - existing.forEach((word, index) => { - if (word && typeof word.word === 'string') { - indexByWord.set(word.word.trim().toLowerCase(), index); - } - }); - let updatedCount = 0; - let insertedCount = 0; - entries.forEach((entry) => { - const key = String(entry.word || '').trim().toLowerCase(); - if (!key) { - return; - } - if (indexByWord.has(key)) { - const idx = indexByWord.get(key); - const base = merged[idx]; - merged[idx] = { - ...base, - meaning: entry.meaning || base.meaning, - example: entry.example || base.example, - freq: typeof entry.freq === 'number' ? entry.freq : base.freq - }; - updatedCount += 1; - return; - } - merged.push({ - word: entry.word, - meaning: entry.meaning, - example: entry.example || '', - freq: typeof entry.freq === 'number' ? entry.freq : undefined - }); - indexByWord.set(key, merged.length - 1); - insertedCount += 1; - }); + const mergeResult = await state.store.mergeWords(entries); + const insertedCount = Number(mergeResult && mergeResult.addedCount) || 0; + const updatedCount = Number(mergeResult && mergeResult.updatedCount) || 0; if (!insertedCount && !updatedCount) { showFeedbackMessage('所有词条均已存在,无需更新', 'info'); return; } - await state.store.setWords(merged); const categoryLabel = meta.category === 'user' ? '自设词表' : '外部词表'; const suffix = sourceLabel ? `「${sourceLabel}」` : ''; showFeedbackMessage(`${categoryLabel}${suffix}导入完成:新增 ${insertedCount} 条,更新 ${updatedCount} 条`, 'success'); @@ -3903,7 +3759,7 @@ state.session.activeQueue.push(clone); } - function rateAndContinue(quality) { + async function rateAndContinue(quality) { const session = state.session; const word = session.currentWord; if (!word || session.stage !== 'feedback') { @@ -3914,9 +3770,17 @@ if (session.lastAnswer && session.lastAnswer.quality !== quality) { const now = new Date(); const patch = state.scheduler.scheduleAfterResult(word, quality, now); - state.store.updateWord(word.id, patch); - session.currentWord = { ...word, ...patch }; - session.lastAnswer.quality = quality; + try { + const committedWord = await state.store.updateWord(word.id, patch); + if (!committedWord) { + throw new Error('词汇记录不存在'); + } + session.currentWord = committedWord; + session.lastAnswer.quality = quality; + } catch (error) { + showFeedbackMessage(`评分保存失败:${error.message || error}`, 'error'); + return; + } } moveToNextWord(); @@ -3948,17 +3812,26 @@ render(); } - function saveCurrentNote() { + async function saveCurrentNote() { const word = state.session.currentWord; if (!word || !state.store || !state.elements.noteInput) { return; } const note = state.elements.noteInput.value.trim(); - state.store.updateWord(word.id, { note }); - state.session.currentWord = { - ...state.session.currentWord, - note - }; + let committedWord; + try { + committedWord = await state.store.updateWord(word.id, { note }); + if (!committedWord) { + throw new Error('词汇记录不存在'); + } + } catch (error) { + if (state.elements.noteStatus) { + state.elements.noteStatus.textContent = '保存失败'; + } + showFeedbackMessage(`笔记保存失败:${error.message || error}`, 'error'); + return false; + } + state.session.currentWord = committedWord; if (state.elements.noteStatus) { state.elements.noteStatus.textContent = '已保存'; setTimeout(() => { @@ -3967,6 +3840,7 @@ } }, 1500); } + return true; } function startBatch(force) { @@ -6011,33 +5885,99 @@ (function (window) { 'use strict'; + /** + * Presentation catalog + notifier for achievements. + * + * Unlock rules and persistence belong entirely to the `achievements.progress` + * projector (js/data/v2/appData.js -> computeAchievementProgress). That projector + * is declared `derived` in the data catalog, is listed in `derivedPending` for every + * practice mutation, and records the historically accurate unlock timestamp for each + * achievement id. + * + * This class therefore owns only display metadata (title / description / icon / tier) + * and diffs successive projector reads so that newly unlocked achievements can be + * surfaced as notifications. It deliberately does NOT re-derive unlock conditions: + * a second rule engine here would drift from the projector (it previously did, which + * left every streak achievement permanently locked) and would stamp "unlocked now" + * instead of the real unlock time. + */ class AchievementManager { constructor() { - this.storageKey = 'user_achievements'; this.achievements = this._defineAchievements(); + this.achievementIds = new Set(this.achievements.map((item) => item.id)); this.listeners = []; this.initialized = false; + // Newest read — what the achievements modal renders. this.unlocked = {}; + // Last read whose projector provenance was proven — what the unlock diff measures + // against. Deliberately separate from `unlocked`: see syncFromAppData. + this.baseline = {}; + this.baselineFresh = false; + this._deliveryInitialized = false; + this._pendingDelivery = {}; + this._initPromise = null; + this._syncTail = Promise.resolve(); } /** - * Initialize the manager, loading state from storage + * Initialize the manager, loading persisted progress from storage. + * + * The first run seeds a durable delivery baseline so existing users are not greeted with + * every historical unlock. Later runs diff against that persisted acknowledgement instead + * of the first projector read, which lets a pending unlock survive a page restart. */ async init() { if (this.initialized) return; + if (this._initPromise) return this._initPromise; + + this._initPromise = this._enqueueSync(() => this._initialize()).finally(() => { + this._initPromise = null; + }); + return this._initPromise; + } + async _initialize() { try { - this.unlocked = await this._loadUnlockedState(); + let [state, delivery] = await Promise.all([ + this._loadUnlockedState(), + this._loadDeliveryState() + ]); + state = await this._retryUntilFresh(state); + this.unlocked = state.unlocked; + if (delivery) { + this.baseline = delivery.acknowledged; + this.baselineFresh = true; + this._deliveryInitialized = true; + } else { + this.baseline = state.unlocked; + this.baselineFresh = state.fresh; + // A brand-new store has no projector provenance yet, but its empty snapshot is + // still a safe delivery baseline: there is no historical unlock to suppress. + if (state.fresh || Object.keys(state.unlocked).length === 0) { + await this._persistDeliveryBaseline(state.unlocked); + this._deliveryInitialized = true; + } + } console.log('[AchievementManager] Initialized. Unlocked:', Object.keys(this.unlocked).length); this.initialized = true; + + if (delivery) { + await this._syncFromAppDataNow({ notify: true, initialState: state }); + } } catch (e) { console.error('[AchievementManager] Init failed', e); this.unlocked = {}; + this.baseline = {}; + this.baselineFresh = false; + this._deliveryInitialized = false; + this.initialized = false; + throw e; } } /** - * Define the list of available achievements + * Display metadata for every achievement the projector can unlock. + * Ids must stay in sync with computeAchievementProgress in js/data/v2/appData.js. */ _defineAchievements() { return [ @@ -6047,32 +5987,28 @@ title: '初出茅庐', description: '累计完成 10 次练习', icon: '🥉', - tier: 1, - condition: (stats) => stats.totalPracticed >= 10 + tier: 1 }, { id: 'practice_silver', title: '渐入佳境', description: '累计完成 50 次练习', icon: '🥈', - tier: 2, - condition: (stats) => stats.totalPracticed >= 50 + tier: 2 }, { id: 'practice_gold', title: '百炼成钢', description: '累计完成 100 次练习', icon: '🥇', - tier: 3, - condition: (stats) => stats.totalPracticed >= 100 + tier: 3 }, { id: 'practice_platinum', title: '千锤百炼', description: '累计完成 200 次练习', icon: '🏅', - tier: 3, - condition: (stats) => stats.totalPracticed >= 200 + tier: 3 }, // --- Streak Milestones --- @@ -6081,32 +6017,28 @@ title: '持之以恒', description: '连续学习 3 天', icon: '🔥', - tier: 1, - condition: (stats) => stats.streakDays >= 3 + tier: 1 }, { id: 'streak_silver', title: '习惯养成', description: '连续学习 7 天', icon: '🔥', - tier: 2, - condition: (stats) => stats.streakDays >= 7 + tier: 2 }, { id: 'streak_gold', title: '意志如铁', description: '连续学习 30 天', icon: '🔥', - tier: 3, - condition: (stats) => stats.streakDays >= 30 + tier: 3 }, { id: 'streak_platinum', title: '长期主义', description: '连续学习 60 天', icon: '🗓️', - tier: 3, - condition: (stats) => stats.streakDays >= 60 + tier: 3 }, // --- Category Mastery: Listening --- @@ -6115,32 +6047,28 @@ title: '开耳第一篇', description: '完成 1 篇听力练习', icon: '🎧', - tier: 1, - condition: (stats) => stats.listeningCount >= 1 + tier: 1 }, { id: 'listening_bronze', title: '顺风耳 (铜)', description: '累计完成 10 篇听力练习', icon: '👂', - tier: 1, - condition: (stats) => stats.listeningCount >= 10 + tier: 1 }, { id: 'listening_silver', title: '顺风耳 (银)', description: '累计完成 50 篇听力练习', icon: '👂', - tier: 2, - condition: (stats) => stats.listeningCount >= 50 + tier: 2 }, { id: 'listening_gold', title: '顺风耳 (金)', description: '累计完成 100 篇听力练习', icon: '👂', - tier: 3, - condition: (stats) => stats.listeningCount >= 100 + tier: 3 }, // --- Category Mastery: Reading --- @@ -6149,32 +6077,28 @@ title: '开卷第一篇', description: '完成 1 篇阅读练习', icon: '📖', - tier: 1, - condition: (stats) => stats.readingCount >= 1 + tier: 1 }, { id: 'reading_bronze', title: '火眼金睛 (铜)', description: '累计完成 10 篇阅读练习', icon: '👁️', - tier: 1, - condition: (stats) => stats.readingCount >= 10 + tier: 1 }, { id: 'reading_silver', title: '火眼金睛 (银)', description: '累计完成 50 篇阅读练习', icon: '👁️', - tier: 2, - condition: (stats) => stats.readingCount >= 50 + tier: 2 }, { id: 'reading_gold', title: '火眼金睛 (金)', description: '累计完成 100 篇阅读练习', icon: '👁️', - tier: 3, - condition: (stats) => stats.readingCount >= 100 + tier: 3 }, // --- Balanced Practice --- @@ -6183,16 +6107,14 @@ title: '双线推进', description: '阅读与听力各完成 10 篇', icon: '⚖️', - tier: 2, - condition: (stats) => stats.readingCount >= 10 && stats.listeningCount >= 10 + tier: 2 }, { id: 'balanced_advanced', title: '均衡进阶', description: '阅读与听力各完成 30 篇', icon: '🧭', - tier: 3, - condition: (stats) => stats.readingCount >= 30 && stats.listeningCount >= 30 + tier: 3 }, // --- Focus Time --- @@ -6201,24 +6123,21 @@ title: '专注一小时', description: '累计学习 60 分钟', icon: '⏱️', - tier: 1, - condition: (stats) => stats.totalStudyMinutes >= 60 + tier: 1 }, { id: 'time_focus_300', title: '沉浸五小时', description: '累计学习 300 分钟', icon: '⏳', - tier: 2, - condition: (stats) => stats.totalStudyMinutes >= 300 + tier: 2 }, { id: 'time_focus_1000', title: '深度备考', description: '累计学习 1000 分钟', icon: '⌛', - tier: 3, - condition: (stats) => stats.totalStudyMinutes >= 1000 + tier: 3 }, // --- Accuracy Milestones --- @@ -6227,48 +6146,42 @@ title: '稳中有进', description: '10 次练习后平均正确率 70%+', icon: '📈', - tier: 2, - condition: (stats) => stats.totalPracticed >= 10 && stats.averageAccuracy >= 0.7 + tier: 2 }, { id: 'accuracy_elite', title: '高分稳定', description: '20 次练习后平均正确率 85%+', icon: '💎', - tier: 3, - condition: (stats) => stats.totalPracticed >= 20 && stats.averageAccuracy >= 0.85 + tier: 3 }, { id: 'perfect_three', title: '三次满分', description: '累计 3 次练习获得满分', icon: '🎯', - tier: 2, - condition: (stats) => stats.perfectCount >= 3 + tier: 2 }, { id: 'perfect_ten', title: '十全十美', description: '累计 10 次练习获得满分', icon: '🏆', - tier: 3, - condition: (stats) => stats.perfectCount >= 10 + tier: 3 }, { id: 'speed_three', title: '快速稳定', description: '3 次 5 分钟内完成高分练习', icon: '⚡', - tier: 2, - condition: (stats) => stats.speedHighScoreCount >= 3 + tier: 2 }, { id: 'speed_ten', title: '闪电节奏', description: '10 次 5 分钟内完成高分练习', icon: '🌩️', - tier: 3, - condition: (stats) => stats.speedHighScoreCount >= 10 + tier: 3 }, // --- Special Achievements --- @@ -6277,383 +6190,208 @@ title: '迈出第一步', description: '完成第一次练习', icon: '🌱', - tier: 1, - condition: (stats) => stats.totalPracticed >= 1 + tier: 1 }, { id: 'accuracy_perfect', title: '神射手', description: '单次练习获得 100% 正确率', icon: '🎯', - tier: 3, - condition: (stats) => stats.hasPerfectAccuracy + tier: 3 }, { id: 'speed_demon', title: '唯快不破', description: '5分钟内完成高分练习', icon: '⚡', - tier: 3, - condition: (stats) => stats.hasSpeedDemon + tier: 3 } ]; } /** - * Load unlocked state from storage + * Read projector-owned unlock progress from storage. + * + * `AppData.achievements.getAll()` attaches a non-enumerable `fresh` flag: false means the + * projector was still pending and the payload is an inline recompute rather than the proven + * cache. That distinction is load-bearing for the unlock diff and delivery retry. */ async _loadUnlockedState() { - if (window.storage) { - return await window.storage.get(this.storageKey, {}); - } - const raw = localStorage.getItem(this.storageKey); - return raw ? JSON.parse(raw) : {}; - } - - /** - * Save unlocked state to storage - */ - async _saveUnlockedState() { - if (window.storage) { - await window.storage.set(this.storageKey, this.unlocked); - return; - } - localStorage.setItem(this.storageKey, JSON.stringify(this.unlocked)); - } - - _getDefaultUserStats() { + const progress = await window.AppData.achievements.getAll(); return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [] + unlocked: this._normalizeProgress(progress), + fresh: !progress || progress.fresh !== false }; } - _getPracticeRecorder() { - const app = window.app; - if (app && app.components && app.components.practiceRecorder) { - return app.components.practiceRecorder; - } - return null; - } - - async _getUserStatsFromPracticeRecordAPI() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - return await window.PracticeRecordAPI.readStats(); - } - const recorder = this._getPracticeRecorder(); - if (recorder && typeof recorder.getUserStats === 'function') { - return await recorder.getUserStats(); - } - return this._getDefaultUserStats(); - } - - /** @deprecated Use _getUserStatsFromPracticeRecordAPI */ - async _getUserStatsFromScoreStorage() { - return this._getUserStatsFromPracticeRecordAPI(); - } - - async _getPracticeRecordsFromPracticeRecordAPI() { - // 使用轻量 listSummary:achievementManager 只需 type/accuracy/duration 等元数据, - // 不需要 answers/correctAnswerMap/suiteEntries 等重字段。listSummary 已从 scoreInfo 投影了 - // accuracy/duration/score 等字段,无需依赖 realData.scoreInfo 后备路径。 - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') { - return await window.PracticeRecordAPI.listSummary(); - } - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return await window.PracticeRecordAPI.list(); - } - const recorder = this._getPracticeRecorder(); - if (recorder && typeof recorder.getPracticeRecords === 'function') { - return await recorder.getPracticeRecords(); + async _loadDeliveryState() { + const settings = await window.AppData.settings.getAll(); + const delivery = settings && settings.achievementDelivery; + if (!delivery || delivery.version !== 1 || !delivery.acknowledged + || typeof delivery.acknowledged !== 'object' || Array.isArray(delivery.acknowledged)) { + return null; } - return []; + return { + acknowledged: Object.fromEntries(Object.entries(delivery.acknowledged) + .filter(([id]) => this.achievementIds.has(id)) + .map(([id, unlockedAt]) => [id, { unlockedAt: unlockedAt || null }])) + }; } - /** @deprecated Use _getPracticeRecordsFromPracticeRecordAPI */ - async _getPracticeRecordsFromScoreStorage() { - return this._getPracticeRecordsFromPracticeRecordAPI(); + async _persistDeliveryBaseline(unlocked) { + if (!window.AppData.achievements + || typeof window.AppData.achievements.acknowledgeDelivery !== 'function') { + throw new Error('AppData.achievements.acknowledgeDelivery is required'); + } + await window.AppData.achievements.acknowledgeDelivery(unlocked); + } + + _unionBaseline(...sources) { + const merged = {}; + sources.forEach((source) => { + Object.entries(source && typeof source === 'object' ? source : {}).forEach(([id, value]) => { + if (!this.achievementIds.has(id)) return; + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const candidateTime = typeof candidate === 'string' ? Date.parse(candidate) : NaN; + const prior = merged[id] && merged[id].unlockedAt; + const priorTime = typeof prior === 'string' ? Date.parse(prior) : NaN; + if (!merged[id] || (Number.isFinite(candidateTime) + && (!Number.isFinite(priorTime) || candidateTime < priorTime))) { + merged[id] = { unlockedAt: Number.isFinite(candidateTime) + ? new Date(candidateTime).toISOString() + : null }; + } + }); + }); + return merged; } - _getCategoryPracticeCount(stats, targetKey) { - if (!stats || !stats.categoryStats || typeof stats.categoryStats !== 'object') { - return 0; + async _retryUntilFresh(initialState) { + let state = initialState; + if (state.fresh || !window.AppData.achievements + || typeof window.AppData.achievements.retryPending !== 'function') { + return state; } - - const normalizedTarget = String(targetKey || '').toLowerCase(); - let count = 0; - - Object.entries(stats.categoryStats).forEach(([key, value]) => { - const normalizedKey = String(key || '').toLowerCase(); - if (normalizedKey !== normalizedTarget) { - return; + for (let attempt = 0; attempt < 3 && !state.fresh; attempt += 1) { + try { + await window.AppData.achievements.retryPending(); + state = await this._loadUnlockedState(); + } catch (err) { + console.warn('[AchievementManager] Failed to retry pending achievement projection', err); } - const practices = value && Number(value.practices); - if (Number.isFinite(practices)) { - count += practices; + if (!state.fresh && attempt < 2) { + await new Promise((resolve) => { + const schedule = window.setTimeout || ((callback) => callback()); + schedule(resolve, 10 * (2 ** attempt)); + }); } - }); - - return count; - } - - _normalizePracticeType(rawType) { - if (!rawType) { - return null; } - - const normalized = String(rawType).toLowerCase(); - if (normalized.includes('listen') || normalized.includes('audio') || normalized.includes('hearing')) { - return 'listening'; - } - if (normalized.includes('read')) { - return 'reading'; - } - return null; + return state; } - _inferRecordPracticeType(record) { - if (!record || typeof record !== 'object') { - return null; - } - - const metadata = record.metadata && typeof record.metadata === 'object' - ? record.metadata + /** + * Reduce the projector payload to `{ [id]: { unlockedAt } }` for ids this + * catalog can render. Unknown ids (e.g. manual entries for retired achievements) + * are dropped because there is no card to show them on. + */ + _normalizeProgress(progress) { + const source = progress && typeof progress === 'object' && !Array.isArray(progress) + ? progress : {}; - const candidates = [ - record.type, - record.practiceType, - metadata.type, - metadata.examType, - metadata.practiceType - ]; + const normalized = {}; - for (const candidate of candidates) { - const normalized = this._normalizePracticeType(candidate); - if (normalized) { - return normalized; + Object.entries(source).forEach(([id, value]) => { + if (!value || id === 'updatedAt' || !this.achievementIds.has(id)) { + return; } - } - - const contextHints = [ - record.examId, - record.url, - record.title, - metadata.url, - metadata.examId, - metadata.examTitle, - metadata.title - ] - .filter(Boolean) - .map((item) => String(item).toLowerCase()) - .join(' '); - - if (/listeningpractice|\/listening\/|listen|audio/.test(contextHints)) { - return 'listening'; - } - if (/reading|睡着过项目组/.test(contextHints)) { - return 'reading'; - } + const unlockedAt = value && typeof value === 'object' ? value.unlockedAt : null; + normalized[id] = { unlockedAt: unlockedAt || null }; + }); - return null; + return normalized; } - _normalizeAccuracy(record) { - if (!record || typeof record !== 'object') { - return 0; - } - - const scoreInfo = record.scoreInfo && typeof record.scoreInfo === 'object' - ? record.scoreInfo - : (record.realData && record.realData.scoreInfo && typeof record.realData.scoreInfo === 'object' - ? record.realData.scoreInfo - : {}); - - const candidates = [ - record.accuracy, - scoreInfo.accuracy - ]; - - for (const candidate of candidates) { - const value = Number(candidate); - if (!Number.isFinite(value)) { - continue; - } - if (value > 1 && value <= 100) { - return value / 100; - } - return Math.max(0, Math.min(1, value)); - } - - return 0; + /** + * Re-read projector progress and report achievements unlocked since the last proven read. + * + * Freshness gates the baseline, not the display. `this.unlocked` always tracks the newest + * read so the achievements modal never renders yesterday's state, while `this.baseline` — + * the set the unlock diff is measured against — only advances on a read whose provenance the + * projector proved. An unproven read that quietly became the baseline would make the next + * read see the unlock as "already known" and drop its notification for good, which is the + * one failure mode with no recovery path: there is no later event that re-raises it. + * + * Consequences of that split: an unproven read never notifies (announcing an unlock the + * proven projection has not confirmed risks a toast for something that never happened, e.g. + * a source snapshot read mid-import), and it never consumes one either — the very next + * proven read still sees the unlock as new and raises it exactly once. + * + * @param {Object} options + * @param {boolean} [options.notify] - surface a toast for each new unlock + */ + syncFromAppData(options = {}) { + return this._enqueueSync(() => this._syncFromAppDataNow(options)); } - _getRecordDuration(record) { - if (!record || typeof record !== 'object') { - return 0; - } - - const scoreInfo = record.scoreInfo && typeof record.scoreInfo === 'object' - ? record.scoreInfo - : (record.realData && record.realData.scoreInfo && typeof record.realData.scoreInfo === 'object' - ? record.realData.scoreInfo - : {}); - - const candidates = [ - record.duration, - record.realData && record.realData.duration, - scoreInfo.duration, - scoreInfo.timeSpent - ]; - - for (const candidate of candidates) { - const value = Number(candidate); - if (Number.isFinite(value) && value >= 0) { - return value; - } - } - - return 0; + _enqueueSync(run) { + const result = this._syncTail.then(run, run); + this._syncTail = result.catch(() => {}); + return result; } - _applyRecordsToDerivedStats(derived, records) { - if (!derived || !Array.isArray(records) || records.length === 0) { - return; - } - - let listeningFromRecords = 0; - let readingFromRecords = 0; - let totalFromRecords = 0; - let totalAccuracyFromRecords = 0; - let accuracyRecordCount = 0; - let totalDurationFromRecords = 0; - - records.forEach((record) => { - if (!record || typeof record !== 'object') { - return; - } - - totalFromRecords += 1; - - const practiceType = this._inferRecordPracticeType(record); - if (practiceType === 'listening') { - listeningFromRecords += 1; - } else if (practiceType === 'reading') { - readingFromRecords += 1; - } - - const accuracy = this._normalizeAccuracy(record); - const duration = this._getRecordDuration(record); - totalAccuracyFromRecords += accuracy; - accuracyRecordCount += 1; - totalDurationFromRecords += duration; - this._applyRecordToDerivedStats(derived, { accuracy, duration }); - }); + async _syncFromAppDataNow(options = {}) { + const { notify = false } = options; + const baseline = this.baseline && typeof this.baseline === 'object' ? this.baseline : {}; - derived.totalPracticed = Math.max(Number(derived.totalPracticed) || 0, totalFromRecords); - derived.listeningCount = Math.max(Number(derived.listeningCount) || 0, listeningFromRecords); - derived.readingCount = Math.max(Number(derived.readingCount) || 0, readingFromRecords); - derived.totalStudyMinutes = Math.max( - Number(derived.totalStudyMinutes) || 0, - totalDurationFromRecords / 60 - ); - if (accuracyRecordCount > 0) { - derived.averageAccuracy = Math.max( - Number(derived.averageAccuracy) || 0, - totalAccuracyFromRecords / accuracyRecordCount - ); + let state = options.initialState || null; + try { + if (!state) state = await this._loadUnlockedState(); + } catch (err) { + console.warn('[AchievementManager] Failed to read achievement progress', err); + return []; } - } - _buildDerivedStats(rawStats) { - const stats = rawStats && typeof rawStats === 'object' ? rawStats : {}; - const averageScore = Number(stats.averageScore) || 0; - return { - totalPracticed: Number(stats.totalPractices) || 0, - streakDays: Number(stats.streakDays) || 0, - totalStudyMinutes: (Number(stats.totalTimeSpent) || 0) / 60, - averageAccuracy: averageScore > 1 && averageScore <= 100 ? averageScore / 100 : averageScore, - listeningCount: this._getCategoryPracticeCount(stats, 'listening'), - readingCount: this._getCategoryPracticeCount(stats, 'reading'), - hasPerfectAccuracy: false, - hasSpeedDemon: false, - perfectCount: 0, - speedHighScoreCount: 0 - }; - } + state = await this._retryUntilFresh(state); - _applyRecordToDerivedStats(derived, record) { - if (!derived || !record) { - return; + const current = state.unlocked; + this.unlocked = current; + if (!state.fresh) { + // Derived cache was unproven (projector pending): display refreshed, baseline held. + this.baselineFresh = false; + return []; } - const accuracy = Number(record.accuracy) || 0; - const duration = Number(record.duration) || 0; - - if (accuracy >= 1) { - derived.hasPerfectAccuracy = true; - derived.perfectCount = (Number(derived.perfectCount) || 0) + 1; - } - if (duration > 0 && duration <= 300 && accuracy > 0.8) { - derived.hasSpeedDemon = true; - derived.speedHighScoreCount = (Number(derived.speedHighScoreCount) || 0) + 1; + if (!this._deliveryInitialized) { + await this._persistDeliveryBaseline(current); + this.baseline = this._unionBaseline(baseline, current); + this.baselineFresh = true; + this._deliveryInitialized = true; + return []; } - } - - async syncFromPracticeRecordAPI(options = {}) { - const { - includeRecords = false, - latestRecord = null, - notify = false - } = options; - const rawStats = await this._getUserStatsFromPracticeRecordAPI(); - const derivedStats = this._buildDerivedStats(rawStats); + const newUnlocks = this.achievements.filter((achievement) => ( + current[achievement.id] && !baseline[achievement.id] + )); - const records = await this._getPracticeRecordsFromPracticeRecordAPI(); - this._applyRecordsToDerivedStats(derivedStats, records); + this.baselineFresh = true; - if (!includeRecords) { - this._applyRecordToDerivedStats(derivedStats, latestRecord); + if (newUnlocks.length > 0 && notify) { + this._notify(newUnlocks); + // Notification delivery is at-least-once across crashes. Within this session, + // advance first so a failed persistence retry cannot repeatedly toast the user. + this.baseline = this._unionBaseline(baseline, current); + this._pendingDelivery = this._unionBaseline(this._pendingDelivery, current); + } else if (newUnlocks.length === 0) { + this.baseline = this._unionBaseline(baseline, current); } - return this._unlockByStats(derivedStats, { notify }); - } - - /** @deprecated Use syncFromPracticeRecordAPI */ - async syncFromScoreStorage(options = {}) { - return this.syncFromPracticeRecordAPI(options); - } - - async _unlockByStats(stats, options = {}) { - const { notify = false } = options; - const newUnlocks = []; - - for (const achievement of this.achievements) { - if (this.unlocked[achievement.id]) continue; - + if (Object.keys(this._pendingDelivery).length > 0) { + const pending = this._pendingDelivery; try { - if (achievement.condition(stats, null)) { - this.unlocked[achievement.id] = { - unlockedAt: new Date().toISOString() - }; - newUnlocks.push(achievement); - } + await this._persistDeliveryBaseline(pending); + this._pendingDelivery = {}; } catch (err) { - console.error(`[AchievementManager] Error checking ${achievement.id}`, err); - } - } - - if (newUnlocks.length > 0) { - await this._saveUnlockedState(); - if (notify) { - this._notify(newUnlocks); + console.warn('[AchievementManager] Failed to persist delivery acknowledgement', err); } } @@ -6661,12 +6399,12 @@ } /** - * Check for new achievements based on latest activity - * @param {Object} latestRecord - The practice record just completed + * Check for newly unlocked achievements after a practice completes. + * The projector has already recomputed progress by this point; we only diff it. */ - async check(latestRecord) { + async check() { if (!this.initialized) await this.init(); - return this.syncFromPracticeRecordAPI({ includeRecords: true, latestRecord, notify: true }); + return this.syncFromAppData({ notify: true }); } /** @@ -6723,7 +6461,7 @@ } } - await window.AchievementManager.syncFromPracticeRecordAPI({ includeRecords: true, notify: false }); + await window.AchievementManager.syncFromAppData({ notify: false }); const all = window.AchievementManager.getAll(); list.innerHTML = all.map(a => `
diff --git a/js/bundles/practice-page-enhancer.bundle.js b/js/bundles/practice-page-enhancer.bundle.js index 91f8fcab..32184247 100644 --- a/js/bundles/practice-page-enhancer.bundle.js +++ b/js/bundles/practice-page-enhancer.bundle.js @@ -1,5 +1,3990 @@ /* Generated by scripts/build-bundles.mjs. Do not edit by hand. */ +/* ===== js/data/practiceRecordSource.js ===== */ +/** + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 + */ +(function initPracticeRecordSource(global) { + 'use strict'; + + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; + } + + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); + + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); + + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); + + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); + + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); + } + + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); + } + + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; + } + + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); + } + + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; + + if (demoSourceSet.has(readMetadataSource(record))) return false; + + return true; + } + + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } + + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } + + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); + + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } + + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; + } + + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; + } + return previewRecordIds.delete(normalizeId(recordId)); + } + + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); + } + + /** + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 + */ + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); + + global.PracticeRecordSource = api; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataCatalog.js ===== */ +(function installDataCatalog(global) { + 'use strict'; + + const V2_SCHEMA_VERSION = 2; + + function clone(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + + function objectDefault() { return {}; } + function arrayDefault() { return []; } + function nullableDefault() { return null; } + function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; } + function normalizeObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {}; + } + function normalizeNullableString(value) { + return value === null || value === undefined || value === '' ? null : String(value); + } + function isArray(value) { return Array.isArray(value); } + function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } + function isNullableString(value) { return value === null || typeof value === 'string'; } + + const CATALOG_OWNERS = new Set([ + 'settings', 'library', 'recovery', 'backups', 'vocab', + 'preferences', 'goals', 'achievements', 'system', 'practice' + ]); + const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']); + const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']); + + function isNonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); + } + + function ownerFromKey(logicalKey) { + const dot = String(logicalKey || '').indexOf('.'); + return dot > 0 ? logicalKey.slice(0, dot) : ''; + } + + function freezeEntry(entry) { + const logicalKey = String(entry.logicalKey || ''); + const owner = ownerFromKey(logicalKey); + const next = Object.assign({}, entry, { + logicalKey, + owner, + schemaVersion: V2_SCHEMA_VERSION, + export: entry.export === true, + import: entry.import || 'ignore' + }); + return Object.freeze(next); + } + + // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations), + // not as document keys. import merge identity is resolved in AppData, not here. + const definitions = [ + { + logicalKey: 'settings.values', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.configurations', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'library.importedIndexes', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.activeConfigurationId', classification: 'authoritative', + defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString, + export: true, import: 'replace' + }, + { + logicalKey: 'recovery.activeSessions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.drafts', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.interrupted', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.windowSession', classification: 'session', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.entries', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'merge-by-id' + }, + { + logicalKey: 'backups.settings', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'backups.exportHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.importHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'vocab.words', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'vocab.userConfig', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'vocab.lists', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'preferences.values', classification: 'preference', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'goals.items', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'achievements.manual', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'achievements.progress', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'system.migrations', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'system.operationJournal', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + } + ].map(freezeEntry); + + function validateCatalog(entries) { + if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry'); + const logicalKeys = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('DataCatalog entry must be an object'); + } + if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) { + throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`); + } + logicalKeys.add(entry.logicalKey); + } + for (const entry of entries) { + if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) { + throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`); + } + if (!CATALOG_CLASSIFICATIONS.has(entry.classification) + || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION + || typeof entry.defaultValue !== 'function' + || typeof entry.normalize !== 'function' + || typeof entry.validate !== 'function' + || typeof entry.export !== 'boolean' + || !IMPORT_POLICIES.has(entry.import)) { + throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`); + } + try { + const defaultValue = entry.defaultValue(); + if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) { + throw new Error('invalid default'); + } + } catch (_) { + throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`); + } + } + return true; + } + + validateCatalog(definitions); + const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry])); + const DataCatalog = Object.freeze({ + version: V2_SCHEMA_VERSION, + list() { return definitions.slice(); }, + get(logicalKey) { + const entry = byKey.get(String(logicalKey || '')); + if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`); + return entry; + }, + has(logicalKey) { return byKey.has(String(logicalKey || '')); }, + validate: validateCatalog, + clone + }); + + Object.defineProperty(global, '__AppDataV2Catalog', { + value: DataCatalog, + enumerable: false, + configurable: true, + writable: false + }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataKernel.js ===== */ +(function installDataKernel(global) { + 'use strict'; + + if (global.AppData) return; + + const catalog = global.__AppDataV2Catalog; + if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel'); + + const DATABASE_NAME = 'IELTSAtlasDataV2'; + // Version 2 uses a new schema, but initialization must still import the durable + // ExamSystemDB data owned by releases which predate AppData v2. + const DATABASE_VERSION = 2; + const DOCUMENT_STORE = 'documents'; + const SYSTEM_STORE = 'system'; + const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES)); + const OPERATION_JOURNAL_WINDOW = 500; + const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`; + const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000; + const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000; + const MAX_TIMER_DELAY_MS = 2147483647; + const LEGACY_DATABASE_NAME = 'ExamSystemDB'; + const LEGACY_STORE_NAME = 'keyValueStore'; + const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup'; + const LEGACY_EXTERNAL_STORE_NAME = 'handles'; + const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory'; + const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json'; + const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([ + 'practice_records', + 'vocab_user_config', + 'user_achievements' + ]); + + function clone(value) { return catalog.clone(value); } + function nowIso() { return new Date().toISOString(); } + function randomId(prefix) { + const random = global.crypto && typeof global.crypto.randomUUID === 'function' + ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `${prefix || 'op'}_${random}`; + } + + class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'AppDataError'; + this.code = code; + this.committed = false; + this.details = details; + } + } + function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); } + function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); } + + function normalizeTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback; + } + function scheduleTimeout(handler, delayMs) { + if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable'); + const handle = global.setTimeout.call(global, handler, delayMs); + if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle'); + return handle; + } + function cancelTimeout(handle) { + if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') { + try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ } + } + } + function withDeadline(handle, timeoutMs, description, resolve, reject) { + let settled = false; + let timer = null; + const settle = (callback, value) => { + if (settled) return; + settled = true; + cancelTimeout(timer); + callback(value); + }; + const expire = (error) => { + if (settled) return; + settled = true; + try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ } + reject(error); + }; + try { + timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, { + operation: description, timeoutMs, reason: 'timeout' + })), timeoutMs); + } catch (error) { + expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, { + operation: description, reason: 'watchdog-unavailable', cause: error && error.message + })); + } + return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } }; + } + + function canonicalizeJson(value, path = '$', ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path }); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') { + throw validation(`Non-JSON value at ${path}`, { path, type: typeof value }); + } + if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path }); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path }); + if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw validation(`Symbol-keyed property at ${path}`, { path }); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item, index) => { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path }); + return canonicalizeJson(item, `${path}[${index}]`, ancestors); + }); + } + const result = {}; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path }); + result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors); + } + return result; + } finally { ancestors.delete(value); } + } + function stableStringifyCanonical(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`; + } + function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); } + function checksum(value) { + const input = stableStringify(value); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + function legacyTimestamp(value) { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity; + if (Number.isFinite(Number(value))) return Number(value); + const parsed = Date.parse(value == null ? '' : String(value)); + return Number.isFinite(parsed) ? parsed : -Infinity; + } + function parseLegacyCandidate(value, outerTimestamp) { + let parsed = value; + let timestamp = legacyTimestamp(outerTimestamp); + const hasOuterTimestamp = timestamp !== -Infinity; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; } + } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') + && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) { + const innerTimestamp = legacyTimestamp(parsed.timestamp); + if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp; + parsed = parsed.data; + } else break; + } + return { value: clone(parsed), timestamp }; + } + function parseLegacyValue(value) { + const candidate = parseLegacyCandidate(value); + return candidate ? candidate.value : clone(value); + } + async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) { + const values = {}; + const candidates = {}; + let readComplete = true; + const consider = (alias, rawValue, timestamp, sourceRank) => { + const candidate = parseLegacyCandidate(rawValue, timestamp); + if (!candidate) return; + const previous = candidates[alias]; + if (!previous || candidate.timestamp > previous.timestamp + || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) { + candidates[alias] = Object.assign(candidate, { sourceRank }); + } + }; + if (indexedDBApi && typeof indexedDBApi.open === 'function') { + await new Promise((resolve) => { + let request; + let createdEmptyDatabase = false; + try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; } + request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); }; + request.onupgradeneeded = () => { + createdEmptyDatabase = true; + try { request.transaction.abort(); } catch (_) {} + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; } + const tx = db.transaction(LEGACY_STORE_NAME, 'readonly'); + const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys(); + const rows = tx.objectStore(LEGACY_STORE_NAME).getAll(); + tx.oncomplete = () => { + (keys.result || []).forEach((key, index) => { + const row = (rows.result || [])[index]; + // v1's keyValueStore persisted { key, value, timestamp } rows. + const validRow = row && typeof row === 'object' + && Object.prototype.hasOwnProperty.call(row, 'key') + && String(row.key) === String(key) + && Object.prototype.hasOwnProperty.call(row, 'value'); + if (!validRow) { + readComplete = false; + return; + } + consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0); + }); + db.close(); resolve(); + }; + tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); }; + }; + }); + } + for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) { + if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue; + for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) { + const key = fallbackStorage.key(index); + if (!key) continue; + const alias = key.startsWith('exam_system_') + ? key.slice('exam_system_'.length) + : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null); + if (!alias) continue; + try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ } + } + } + for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value; + Object.defineProperty(values, '__legacyReadComplete', { + value: readComplete, + enumerable: false, + configurable: false, + writable: false + }); + return values; + } + async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) { + if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null; + const directoryHandle = await new Promise((resolve) => { + let request; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value || null); + }; + try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; } + request.onerror = () => finish(null); + request.onupgradeneeded = () => { + try { request.transaction.abort(); } catch (_) {} + finish(null); + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) { + db.close(); finish(null); return; + } + const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly') + .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY); + get.onerror = () => { db.close(); finish(null); }; + get.onsuccess = () => { db.close(); finish(get.result); }; + }; + }); + if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null; + if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null; + const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false }); + const parsed = JSON.parse(await (await fileHandle.getFile()).text()); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined + ? parsed.data : parsed; + return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null; + } + function lookupEntry(logicalKey) { + if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey }); + return catalog.get(logicalKey); + } + function storeFor(logicalKey) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey }); + return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE; + } + function makeEnvelope(entry, data, options = {}) { + const state = options.state === 'cleared' ? 'cleared' : 'present'; + if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`); + let normalized = null; + if (state === 'present') { + try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) { + throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message }); + } + normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`); + if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey }); + } + const revision = options.revision === undefined ? 1 : Number(options.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`); + const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')), + updatedAt: options.updatedAt || nowIso(), state, data: normalized }; + payload.checksum = checksum(payload.data); + return Object.freeze(payload); + } + function validateEnvelope(entry, envelope) { + try { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) + || Number(envelope.schemaVersion) !== Number(entry.schemaVersion) + || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1 + || typeof envelope.operationId !== 'string' || !envelope.operationId + || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt + || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false; + const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`); + return (envelope.state !== 'cleared' || data === null) + && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data); + } catch (_) { return false; } + } + function operationId(value) { + if (value === undefined || value === null || value === '') return randomId('mutation'); + if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string'); + return value; + } + function expectedRevision(value, label) { + if (value === undefined || value === null) return null; + const revision = Number(value); + if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`); + return revision; + } + function compactJournal(journal) { + const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence)); + for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]]; + } + function readJournal(row) { + const envelope = row && row.envelope; + return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? clone(envelope.data) : {}; + } + function journalResult(journal, spec) { + const existing = journal[spec.operationId]; + if (!existing) return null; + if (existing.fingerprint !== spec.fingerprint || !existing.receipt) { + throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId }); + } + return clone(existing.receipt); + } + function writeJournal(journal, spec, receipt) { + const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1; + journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() }; + compactJournal(journal); + return journal; + } + function putJournal(tx, currentRow, journal, spec, receipt) { + const current = currentRow && currentRow.envelope; + const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), { + revision: current ? Number(current.revision) + 1 : 1, + operationId: spec.operationId, + normalized: true + }); + tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) }); + } + + class IndexedDBDriver { + constructor(indexedDBApi, options) { + this.indexedDB = indexedDBApi; + this.db = null; + this.mutationTimeoutMs = options.mutationTimeoutMs; + this.requestTimeoutMs = options.requestTimeoutMs; + } + async initialize() { + if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable'); + this.db = await new Promise((resolve, reject) => { + const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + let abandoned = false; + let settle; + request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion < 2) { + for (const name of ['authoritative', 'derived']) { + if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name); + } + } + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' }); + } + }; + settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject); + request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB')); + request.onblocked = () => { + abandoned = true; + settle.reject(new Error('IndexedDB upgrade blocked')); + }; + }); + this.db.onversionchange = () => this.close(); + return this; + } + close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} } + _open() { if (!this.db) throw new Error('IndexedDB connection closed'); } + _transaction(stores, mode, description, work, mutation = false) { + this._open(); + return new Promise((resolve, reject) => { + let failure = null; + let value; + let tx; + try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; } + const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject); + tx.oncomplete = () => settle.resolve(clone(value)); + tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); }; + tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`)); + const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} }; + try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); } + }); + } + readEnvelope(logicalKey) { + const store = storeFor(logicalKey); + return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(logicalKey); + request.onsuccess = () => done(request.result ? request.result.envelope : null); + request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`)); + }); + } + readEntity(store, recordId) { + return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(recordId); + request.onsuccess = () => done(request.result || null); + request.onerror = () => fail(request.error || new Error('Entity read failed')); + }); + } + readPracticeSnapshot(recordIds = null, options = {}) { + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES.slice(); + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean)); + return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => { + const result = Object.fromEntries(stores.map((store) => [store, []])); + let remaining = stores.length; + const finishStore = (store, rows) => { + result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || ''))); + remaining -= 1; + if (!remaining) done(result); + }; + for (const store of stores) { + const objectStore = tx.objectStore(store); + const request = requested && requested.size === 1 + ? objectStore.get(Array.from(requested)[0]) + : objectStore.getAll(); + request.onsuccess = () => { + const rows = requested && requested.size === 1 + ? (request.result ? [request.result] : []) + : request.result; + finishStore(store, rows); + }; + request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`)); + } + }); + } + listEntities(store) { + return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => { + const request = tx.objectStore(store).getAll(); + request.onsuccess = () => done(request.result || []); + request.onerror = () => fail(request.error || new Error('Entity list failed')); + }); + } + atomic(spec) { + return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => { + const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal'); + journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed')); + journalRequest.onsuccess = () => { + try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); } + }; + }, true); + } + exportSnapshot(envelopeKeys) { + return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => { + const result = { envelopes: {}, entities: {} }; + let remaining = STORE_NAMES.length; + for (const store of STORE_NAMES) { + const request = tx.objectStore(store).getAll(); + request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`)); + request.onsuccess = () => { + if (store === DOCUMENT_STORE || store === SYSTEM_STORE) { + for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope; + } else result.entities[store] = request.result || []; + remaining -= 1; + if (!remaining) done(result); + }; + } + }); + } + } + + function entityStore(store) { + const value = String(store || ''); + if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value }); + return value; + } + function validateEntityRow(store, row) { + if (!row || typeof row !== 'object' || Array.isArray(row) + || typeof row.recordId !== 'string' || !row.recordId + || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1 + || typeof row.operationId !== 'string' || !row.operationId + || typeof row.updatedAt !== 'string' || !row.updatedAt) { + throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null }); + } + const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`); + if (row.checksum !== checksum(data)) { + throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId }); + } + return row; + } + function normalizeEntityOperation(operation, index) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`); + const type = String(operation.type || ''); + const store = entityStore(operation.store); + if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`); + const recordId = type === 'clear' ? null : String(operation.recordId || ''); + if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`); + const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null; + return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) }; + } + function receiptFor(operationIdValue, revisions, warnings, pending) { + const receipt = { committed: true, revisions, operationId: operationIdValue, + derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() }; + const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]]; + return receipt; + } + + class DataKernel { + constructor(options = {}) { + this.driver = null; + this.backend = null; + this.state = 'created'; + this.failure = null; + this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB; + this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS); + this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS); + this.committedListeners = new Set(); + this.commitChannel = null; + this.instanceId = randomId('kernel'); + this.ready = null; + } + _initializeCommitChannel() { + if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return; + try { + const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME); + channel.onmessage = (message) => { + const data = message && message.data; + if (!data || data.sourceInstanceId === this.instanceId + || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return; + this._dispatchCommitted({ + operationId: data.operationId, + targets: clone(data.targets), + receipt: data.receipt ? clone(data.receipt) : null, + remote: true + }); + }; + this.commitChannel = channel; + } catch (_) { + this.commitChannel = null; + } + } + _closeCommitChannel() { + const channel = this.commitChannel; + this.commitChannel = null; + try { if (channel) channel.close(); } catch (_) {} + } + initialize() { + if (this.ready) return this.ready; + this.state = 'initializing'; + this.ready = new IndexedDBDriver(this.indexedDB, { + mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs + }).initialize().then((driver) => { + this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this; + }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null; + throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); }); + return this.ready; + } + close() { + if (this.driver) this.driver.close(); + this.driver = null; this.backend = null; + this._closeCommitChannel(); + if (this.state !== 'failed') this.state = 'closed'; + } + _assertReady() { + if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message }); + if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized'); + } + _latch(error) { + if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message }); + this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel(); + return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message }); + } + onCommitted(listener) { + if (typeof listener !== 'function') throw validation('Committed listener must be a function'); + this.committedListeners.add(listener); return () => this.committedListeners.delete(listener); + } + _dispatchCommitted(event) { + if (!event || !this.committedListeners.size) return; + const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback); + schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} })); + } + _notifyCommitted(targets, receipt) { + if (!targets.length) return; + const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false }; + this._dispatchCommitted(event); + if (this.commitChannel) { + try { + this.commitChannel.postMessage({ + sourceInstanceId: this.instanceId, + operationId: event.operationId, + targets: event.targets, + receipt: event.receipt + }); + } catch (_) { /* cross-realm notification is best effort */ } + } + } + async getEnvelope(logicalKey) { + this._assertReady(); const entry = lookupEntry(logicalKey); + try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async read(logicalKey, options = {}) { + const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey); + const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data; + return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data); + } + _documentSpec(changes, options) { + if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes'); + const opId = operationId(options.operationId); const seen = new Set(); + const prepared = changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`); + const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey); + if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel'); + if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey); + const state = change.state === 'cleared' ? 'cleared' : 'present'; + if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`); + if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`); + let data = null; + if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); } + return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) }; + }); + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings }); + return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) }; + } + async mutate(changes, options = {}) { + this._assertReady(); const spec = this._documentSpec(changes, options); + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) })); + let remaining = reads.length; + const finish = () => { + const revisions = {}; + for (const item of reads) { + const current = item.request.result ? item.request.result.envelope : null; + if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey }); + const revision = current ? Number(current.revision) : 0; + if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision }); + const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true }); + tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision; + } + const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []); + putJournal(tx, journalRow, journal, spec, receipt); + done(receipt); + }; + if (!remaining) { finish(); return; } + for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification })); + this._notifyCommitted(targets, receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); } + async readEntity(store, recordId, options = {}) { + this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId'); + try { + const row = await this.driver.readEntity(store, id); + if (!row) return null; + validateEntityRow(store, row); + return options.withMeta ? clone(row) : clone(row.data); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async readPracticeSnapshot(recordIds = null, options = {}) { + this._assertReady(); + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + try { + const snapshot = await this.driver.readPracticeSnapshot(ids, options); + const result = {}; + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES; + for (const store of stores) { + const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : []) + .filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + result[store] = options.withMeta + ? clone(validRows) + : validRows.map((row) => clone(row.data)); + } + return result; + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async listEntities(store, options = {}) { + this._assertReady(); store = entityStore(store); + if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId'); + try { + const rows = await this.driver.listEntities(store); + const validRows = rows.filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data)); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async mutateEntities(operations, options = {}) { + this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations'); + const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set(); + for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); } + for (const store of ENTITY_STORES) { + const scoped = items.filter((item) => item.store === store); + if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) { + throw validation(`Entity clear cannot be combined with other operations for ${store}`); + } + } + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length; + const finish = () => { const revisions = {}; + for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0; + if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`); + const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; } + } + for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; } + const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); }; + if (!remaining) { try { finish(); } catch (error) { fail(error); } return; } + for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async exportSnapshot(options = {}) { + this._assertReady(); + try { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null; + const shouldExport = (logicalKey) => { + if (!catalog.has(logicalKey)) return false; + const entry = lookupEntry(logicalKey); + if (selected && !selected.has(logicalKey)) return false; + if (entry.export === true) return true; + return options.includeSystem === true && entry.classification === 'system'; + }; + const data = await this.driver.exportSnapshot(shouldExport); + // Full/partial snapshots must be dense for their declared catalog + // range. An absent physical row means the catalog default, not an + // instruction that future importers should guess about. + for (const entry of catalog.list()) { + if (!shouldExport(entry.logicalKey) + || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue; + data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, { + state: 'cleared', + operationId: 'snapshot-default' + }); + } + if (Array.isArray(options.entityStores)) { + const selectedStores = new Set(options.entityStores.map(entityStore)); + for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store]; + } + const payload = { envelopes: data.envelopes, entities: data.entities }; + return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) }; + } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async installSnapshot(snapshot, options = {}) { + this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} }; + const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities'); + if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid'); + if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch'); + const changes = []; + for (const [logicalKey, envelope] of Object.entries(envelopes)) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue; + if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`); + changes.push({ logicalKey, entry, envelope }); + } + const entityRows = {}; + for (const store of ENTITY_STORES) { + if (!Object.prototype.hasOwnProperty.call(entities, store)) continue; + const rows = entities[store]; + if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`); + const ids = new Set(); + entityRows[store] = rows.map((row) => { + if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`); + const recordId = String(row.recordId); + if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`); + ids.add(recordId); + const data = canonicalizeJson(row.data); + if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`); + const revision = row.revision === undefined ? 1 : Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`); + return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) }; + }); + } + if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data'); + const resetJournal = options.resetJournal === true; + const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object' + ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken') + : null; + const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {}; + const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {}; + const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({ + kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey) + })).concat(Object.entries(entityChecks).map(([store, expected]) => ({ + kind: 'entities', store, expected, request: tx.objectStore(store).getAll() + }))); + const finish = () => { + for (const read of reads) { + if (read.kind === 'document') { + const current = read.request.result ? read.request.result.envelope : null; + const actualRevision = current ? Number(current.revision) : 0; + const expectedRevision = Number(read.expected) || 0; + if (actualRevision !== expectedRevision) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision }); + } + } else { + const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0])); + const expected = read.expected && typeof read.expected === 'object' ? read.expected : {}; + const ids = new Set(Object.keys(actual).concat(Object.keys(expected))); + for (const recordId of ids) { + const current = actual[recordId] || 0; + const wanted = Number(expected[recordId]) || 0; + if (current !== wanted) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId }); + } + } + } + } + const revisions = {}; + for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); } + for (const [store, rows] of Object.entries(entityRows)) { + tx.objectStore(store).clear(); + for (const row of rows) tx.objectStore(store).put(row); + } + const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt); + }; + if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; } + let remaining = reads.length; + for (const read of reads) { + read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed')); + read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; + } + } })); + const targets = changes + .filter((item) => item.entry.owner !== 'backups') + .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification })) + .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' }))); + this._notifyCommitted(targets, receipt); + return receipt; + } + catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); } + } + + Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/appData.js ===== */ +(function installAppData(global) { + 'use strict'; + + const internals = global.__AppDataV2Internals; + if (!internals || typeof internals.DataKernel !== 'function') { + throw new Error('AppData v2 requires DataKernel'); + } + const { + DataKernel, + AppDataError, + catalog, + clone, + randomId, + nowIso, + checksum + } = internals; + const kernel = new DataKernel(); + const importPlans = new Map(); + const RECOVERY_KEYS = Object.freeze({ + activeSession: 'recovery.activeSessions', + draft: 'recovery.drafts', + interrupted: 'recovery.interrupted', + rejectedCompletion: 'recovery.rejectedCompletions' + }); + const PREFERENCE_FIELDS = Object.freeze({ + theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode', + resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay', + threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget', + consent: 'consent', logConfig: 'logConfig' + }); + const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + + function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function asArray(value) { return Array.isArray(value) ? value : []; } + function idOf(value, fields) { + for (const field of fields) { + if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]); + } + return ''; + } + function importedLibraryId(value, options = {}) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id && options.nullable) return null; + if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required'); + if (/^exam_index(?:_|$)/.test(id)) { + throw new AppDataError('VALIDATION', 'Unsupported library configuration id'); + } + return id; + } + function assertObject(value, message) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function assertArray(value, message) { + if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function jsonValue(value, label = 'value') { + try { + const serialized = JSON.stringify(value, (_key, current) => { + if (typeof current === 'bigint') return String(current); + if (typeof current === 'number' && !Number.isFinite(current)) return null; + return current; + }); + if (serialized === undefined) return null; + return JSON.parse(serialized); + } catch (error) { + throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message }); + } + } + function operationId(command, prefix, semanticPayload = command) { + const id = command && command.operationId ? String(command.operationId) : randomId(prefix); + jsonValue(semanticPayload, `${prefix} payload`); + return id; + } + function mutationOptions(command, prefix, semanticPayload, extra = {}) { + const source = asObject(command); + const payload = jsonValue(semanticPayload, `${prefix} payload`); + const intent = { command: prefix, payload }; + if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) { + intent.expectedRevision = source.expectedRevision; + } + return Object.assign({}, extra, { + operationId: operationId(source, prefix, payload), + intent + }); + } + function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) { + return mutationOptions(asObject(options), prefix, semanticPayload, extra); + } + function deterministicEntityId(prefix, operation) { + return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`; + } + function normalizeAccuracyRatio(value, label = 'accuracy') { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`); + } + return numeric > 1 ? numeric / 100 : numeric; + } + function defaultStats() { + return { + totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0, + reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + lastUpdated: nowIso() + }; + } + + function nonNegativeScalar(value) { + if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + } + + function firstNonNegativeScalar(...values) { + for (const value of values) { + const numeric = nonNegativeScalar(value); + if (numeric !== null) return numeric; + } + return null; + } + + function normalizeAnswerQuestionId(value, index = 0) { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || `q${index + 1}`; + } + + function normalizeAnswerValue(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); + if (value && typeof value === 'object') { + if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); + if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); + if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); + return jsonValue(value, 'practice answer'); + } + return typeof value === 'string' ? value : String(value); + } + + function normalizeAnswerMap(value) { + const normalized = {}; + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry === undefined || entry === null) return; + const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; + const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); + normalized[questionId] = normalizeAnswerValue( + hasOwn(item, 'answer') ? item.answer + : hasOwn(item, 'userAnswer') ? item.userAnswer + : hasOwn(item, 'value') ? item.value + : entry + ); + }); + return normalized; + } + if (!value || typeof value !== 'object') return normalized; + Object.entries(value).forEach(([questionId, answer], index) => { + if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; + normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); + }); + return normalized; + } + + function mergeAnswerMaps(...sources) { + const merged = {}; + for (const source of sources) { + const normalized = normalizeAnswerMap(source); + for (const [questionId, answer] of Object.entries(normalized)) { + if (!hasOwn(merged, questionId)) merged[questionId] = answer; + } + } + return merged; + } + + function canonicalizeAnswerSource(record) { + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const rawRealData = asObject(rawData.realData); + record.answers = mergeAnswerMaps( + record.answers, + record.answerMap, + record.answerList, + realData.answers, + realData.answerMap, + rawData.answers, + rawData.answerMap, + rawRealData.answers + ); + // These names remain accepted only at the compatibility boundary. The + // canonical detail layer owns one user-answer source: `answers`. + delete record.answerMap; + delete record.answerList; + } + + function normalizePracticeScoreFields(record) { + const scoreInfo = asObject(record.scoreInfo); + const realScoreInfo = asObject(asObject(record.realData).scoreInfo); + const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); + const overloadedCorrectAnswers = record.correctAnswers; + const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; + if (isAnswerMap) { + const existingMap = record.correctAnswerMap; + const overloadedObject = asObject(overloadedCorrectAnswers); + const existingObject = asObject(existingMap); + if (Object.keys(overloadedObject).length) { + record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); + } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { + record.correctAnswerMap = clone(overloadedCorrectAnswers); + } + } + + let correctAnswers = firstNonNegativeScalar( + overloadedCorrectAnswers, + record.correctAnswersCount, + record.correctCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct, + rawScoreInfo.correctAnswers, + rawScoreInfo.correct + ); + if (correctAnswers === null) { + const comparisons = asArray(record.answerComparison).length + ? asArray(record.answerComparison) + : asArray(asObject(record.realData).answerComparison); + if (comparisons.length) { + correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; + } + } + if (correctAnswers !== null) record.correctAnswers = correctAnswers; + else if (isAnswerMap) record.correctAnswers = 0; + + const totalQuestions = firstNonNegativeScalar( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total, + rawScoreInfo.totalQuestions, + rawScoreInfo.total + ); + if (totalQuestions !== null) record.totalQuestions = totalQuestions; + } + + function canonicalizeRecord(input) { + assertObject(input, 'practice record must be an object'); + const record = jsonValue(input, 'practice record'); + record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record'); + record.sessionId = idOf(record, ['sessionId']) || record.id; + record.timestamp = record.timestamp || record.completedAt || record.date || nowIso(); + record.completedAt = record.completedAt || record.timestamp; + record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice'; + record.metadata = asObject(record.metadata); + if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; + if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; + canonicalizeAnswerSource(record); + normalizePracticeScoreFields(record); + for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { + if (record[field] === undefined || record[field] === null || record[field] === '') continue; + const numeric = Number(record[field]); + if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`); + record[field] = numeric; + } + if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy'); + return jsonValue(record, 'canonical practice record'); + } + + function deriveQuestionTypeErrorCounts(source) { + const record = asObject(source); + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const counts = {}; + const addCount = (type, value) => { + const key = String(type || 'other').trim() || 'other'; + const amount = Math.max(0, Number(value) || 0); + if (amount > 0) counts[key] = (counts[key] || 0) + amount; + }; + const performanceSources = [ + record.questionTypePerformance, + realData.questionTypePerformance, + rawData.questionTypePerformance + ]; + let hasPerformanceData = false; + for (const performanceMap of performanceSources) { + if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; + let sourceHasPerformanceData = false; + for (const [type, value] of Object.entries(performanceMap)) { + const performance = asObject(value); + const total = Number(performance.total ?? performance.totalQuestions); + const correct = Number(performance.correct ?? performance.correctAnswers); + if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; + hasPerformanceData = true; + sourceHasPerformanceData = true; + addCount(type, total - correct); + } + if (sourceHasPerformanceData) break; + } + if (hasPerformanceData) return counts; + + const questionTypeMap = Object.assign( + {}, + asObject(rawData.questionTypeMap), + asObject(realData.questionTypeMap), + asObject(record.questionTypeMap) + ); + const normalizedTypeMap = {}; + for (const [questionId, type] of Object.entries(questionTypeMap)) { + normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; + } + const detailSources = [ + record.answerDetails, + asObject(record.scoreInfo).details, + realData.answerDetails, + asObject(realData.scoreInfo).details, + rawData.answerDetails, + asObject(rawData.scoreInfo).details + ]; + const seenQuestions = new Set(); + for (const details of detailSources) { + if (!details || typeof details !== 'object' || Array.isArray(details)) continue; + for (const [questionId, value] of Object.entries(details)) { + const detail = asObject(value); + const normalizedId = String(questionId).trim().toLowerCase(); + if (!normalizedId || seenQuestions.has(normalizedId)) continue; + let isWrong = detail.isCorrect === false || detail.correct === false; + if (detail.isCorrect === true || detail.correct === true) isWrong = false; + else if (!isWrong) { + const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); + const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); + isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); + } + if (!isWrong) continue; + seenQuestions.add(normalizedId); + addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); + } + } + return counts; + } + + function lightSuiteEntry(source, fallbackType = null) { + const entry = asObject(source); + const scoreInfo = asObject(entry.scoreInfo); + const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); + const metadata = asObject(entry.metadata); + const totalQuestions = firstNonNegativeScalar( + entry.totalQuestions, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total + ) ?? 0; + const correctAnswers = firstNonNegativeScalar( + entry.correctAnswers, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct + ) ?? 0; + const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'suite entry accuracy' + ) || 0; + const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0; + return jsonValue({ + id: entry.id || null, + sessionId: entry.sessionId || null, + examId: entry.examId || metadata.examId || null, + title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', + type: entry.type || metadata.type || metadata.examType || fallbackType || null, + date: entry.date || entry.completedAt || entry.timestamp || null, + duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage, + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + }, 'suite entry light projection'); + } + + function lightFromCanonical(source) { + const scoreInfo = asObject(source.scoreInfo); + const realScoreInfo = asObject(asObject(source.realData).scoreInfo); + const metadata = asObject(source.metadata); + const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const dataSource = hasOwn(source, 'dataSource') + ? source.dataSource + : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined); + const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0; + const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0; + const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'practice light accuracy' + ) || 0; + return jsonValue({ + id: source.id, + sessionId: source.sessionId, + examId: source.examId || source.metadata.examId || null, + title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '', + type: source.type, + mode: source.mode || source.practiceMode || null, + timestamp: source.timestamp, + completedAt: source.completedAt, + date: source.date || source.completedAt || source.timestamp || null, + startTime: source.startTime || null, + endTime: source.endTime || null, + duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, + score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` + // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 + // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 + dataSource, + // Summaries are list indexes. Keep only the metadata needed to filter, show a + // source label, or locate the originating library; details stay in their entity. + metadata: Object.fromEntries([ + // `source` must stay: PracticeRecordSource uses metadata.source demo markers + // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full. + 'examId', 'examTitle', 'title', 'type', 'category', 'frequency', + 'dataSource', 'source', 'libraryConfigurationId' + ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), + suite: source.suite == null ? null : clone(asObject(source.suite)), + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + }, 'practice light projection'); + } + + function projectLight(record) { + if (!record) return null; + return lightFromCanonical(canonicalizeRecord(record)); + } + + function firstNonEmpty(...values) { + let first; + for (const value of values) { + if (value === undefined || value === null) continue; + if (first === undefined) first = value; + if (Array.isArray(value) && value.length) return clone(value); + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value); + if (typeof value !== 'object') return clone(value); + } + return first === undefined ? {} : clone(first); + } + + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); + + function withoutRawData(value) { + if (Array.isArray(value)) return value.map(withoutRawData); + if (!value || typeof value !== 'object') return clone(value); + const clean = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item); + } + return clean; + } + + function splitPracticeRecord(input) { + const source = canonicalizeRecord(input); + const summary = lightFromCanonical(source); + const detail = { recordId: source.id }; + const annotations = { recordId: source.id }; + for (const [key, value] of Object.entries(source)) { + if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); + else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { + const next = Object.assign({}, asObject(entry)); + canonicalizeAnswerSource(next); + const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); + for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); + } + const annotation = {}; + for (const annotationKey of ANNOTATION_FIELDS) { + if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; } + if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey]; + if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey]; + } + delete next.realData; delete next.rawData; + if (Object.keys(annotation).length) { + if (!annotations.suiteEntries) annotations.suiteEntries = {}; + annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation; + } + return withoutRawData(next); + }); + else detail[key] = withoutRawData(value); + } + // Accept the old mirror only as an input normalization boundary; it is never persisted. + const realData = asObject(source.realData); const rawData = asObject(source.rawData); + for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); + } + for (const key of ANNOTATION_FIELDS) { + if (hasOwn(annotations, key)) continue; + if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]); + else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]); + } + return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') }; + } + + function joinPracticeRecord(summary, detail, annotations, projection = 'full') { + if (!summary) return null; + const mode = String(projection || 'full').toLowerCase(); + const light = clone(summary); + if (mode === 'light' || mode === 'summary') return light; + const joined = Object.assign({}, light, clone(asObject(detail))); + delete joined.recordId; + if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection'); + const annotationData = asObject(annotations); + for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value); + if (Array.isArray(joined.suiteEntries)) { + const suiteAnnotations = asObject(annotationData.suiteEntries); + joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {}))); + } + return jsonValue(joined, 'practice full projection'); + } + + function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); } + + // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。 + // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定, + // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败, + // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。 + const practiceRecordSource = global.PracticeRecordSource; + if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') { + throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)'); + } + const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord; + + function computeStats(records) { + const stats = defaultStats(); + for (const record of asArray(records).filter(isRealPracticeRecord)) { + const summary = projectLight(record); + const type = String(summary.type || '').toLowerCase(); + const target = type.includes('listen') ? stats.listening : stats.reading; + stats.totalPractices += 1; + stats.totalQuestions += summary.totalQuestions; + stats.correctAnswers += summary.correctAnswers; + target.practices += 1; + target.questions += summary.totalQuestions; + target.correct += summary.correctAnswers; + } + stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0; + for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0; + stats.lastUpdated = nowIso(); + return stats; + } + + function validIso(value) { + if (value === null || value === undefined || value === '') return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function practiceType(record) { + const metadata = asObject(record.metadata); + const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType, + record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase(); + if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening'; + if (hints.includes('read')) return 'reading'; + return null; + } + + function accuracyRatio(record) { + const summary = lightFromCanonical(record); + const value = Number(summary.accuracy); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } + + function durationSeconds(record) { + const scoreInfo = asObject(record.scoreInfo); + const realData = asObject(record.realData); + const realScoreInfo = asObject(realData.scoreInfo); + for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; + } + return 0; + } + + function earlierUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function laterUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function computeAchievementProgress(records, manual, existing) { + const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord) + .map((record, index) => ({ + record, + index, + unlockedAt: validIso(record.completedAt || record.timestamp), + time: new Date(record.completedAt || record.timestamp).getTime() + })) + .sort((left, right) => { + const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER; + const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER; + return leftTime - rightTime || left.index - right.index; + }); + const candidates = {}; + const setThreshold = (id, list, count) => { + if (list.length >= count) candidates[id] = list[count - 1].unlockedAt; + }; + setThreshold('first_step', items, 1); + setThreshold('practice_bronze', items, 10); + setThreshold('practice_silver', items, 50); + setThreshold('practice_gold', items, 100); + setThreshold('practice_platinum', items, 200); + + const reading = items.filter((item) => practiceType(item.record) === 'reading'); + const listening = items.filter((item) => practiceType(item.record) === 'listening'); + setThreshold('reading_first', reading, 1); + setThreshold('reading_bronze', reading, 10); + setThreshold('reading_silver', reading, 50); + setThreshold('reading_gold', reading, 100); + setThreshold('listening_first', listening, 1); + setThreshold('listening_bronze', listening, 10); + setThreshold('listening_silver', listening, 50); + setThreshold('listening_gold', listening, 100); + if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt); + if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt); + + let cumulativeDuration = 0; + let cumulativeAccuracy = 0; + let perfectCount = 0; + let speedCount = 0; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const accuracy = accuracyRatio(item.record); + const duration = durationSeconds(item.record); + cumulativeDuration += duration; + cumulativeAccuracy += accuracy; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt; + if (accuracy >= 1) { + perfectCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt; + if (perfectCount === 3) candidates.perfect_three = item.unlockedAt; + if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt; + } + if (duration > 0 && duration <= 300 && accuracy > 0.8) { + speedCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt; + if (speedCount === 3) candidates.speed_three = item.unlockedAt; + if (speedCount === 10) candidates.speed_ten = item.unlockedAt; + } + } + + const dayItems = new Map(); + for (const item of items) { + if (!item.unlockedAt) continue; + const day = item.unlockedAt.slice(0, 10); + if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt); + } + const days = Array.from(dayItems.keys()).sort(); + let streak = 0; + let previousDay = null; + for (const day of days) { + const currentDay = new Date(`${day}T00:00:00.000Z`).getTime(); + streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1; + previousDay = currentDay; + if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day); + if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day); + if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day); + if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day); + } + + const progress = {}; + const mergeUnlocked = (source) => { + for (const [rawId, value] of Object.entries(asObject(source))) { + if (!value || rawId === 'updatedAt') continue; + const id = rawId; + const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null; + if (!progress[id]) progress[id] = { unlockedAt }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + }; + mergeUnlocked(existing); + mergeUnlocked(manual); + for (const [id, unlockedAt] of Object.entries(candidates)) { + if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + return jsonValue(progress, 'achievement progress'); + } + + // Entity records are authoritative. Projections are assembled on reads, never cached or + // scheduled as follow-up work; this keeps a successful write immediately observable. + async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } + + async function retryMergeConflict(options, task, maxAttempts = 3) { + const explicitRevision = hasOwn(options, 'expectedRevision'); + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) { + throw error; + } + } + } + throw lastError; + } + + async function readCollectionMeta(logicalKey) { + const meta = await kernel.read(logicalKey, { withMeta: true }); + return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 }; + } + + function retainBackupEntries(items, limit = 20, preserveIds = []) { + const cap = Math.max(1, Number(limit) || 20); + const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || '')); + const entries = asArray(items).filter(Boolean).sort(newestFirst); + const retained = []; + const retainedIds = new Set(); + const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean)); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap) break; + if (retainedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + return retained; + } + + function hasOwn(value, key) { + return Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); + } + + function normalizeLibraryConfigurationId(value) { + return importedLibraryId(value, { nullable: true }); + } + + async function practiceRecordWithLibraryProvenance(source, command, options = {}) { + assertObject(source, 'practice record must be an object'); + const record = jsonValue(source, 'practice record'); + const metadata = asObject(record.metadata); + let configurationId; + + if (hasOwn(command, 'libraryConfigurationId')) { + configurationId = command.libraryConfigurationId; + } else if (hasOwn(metadata, 'libraryConfigurationId')) { + configurationId = metadata.libraryConfigurationId; + } else if (hasOwn(record, 'libraryConfigurationId')) { + configurationId = record.libraryConfigurationId; + } else { + configurationId = await kernel.read('library.activeConfigurationId'); + } + + const normalizedId = normalizeLibraryConfigurationId(configurationId); + record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId }); + + if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) { + record.suiteEntries = record.suiteEntries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const next = jsonValue(entry, 'practice suite entry'); + const entryMetadata = asObject(next.metadata); + const entryId = hasOwn(entryMetadata, 'libraryConfigurationId') + ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId) + : normalizedId; + next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId }); + return next; + }); + } + + return record; + } + + function practiceRecordMatches(record, identities) { + const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean)); + if (!expected.size || !record || typeof record !== 'object') return false; + return ['id', 'recordId', 'sessionId'].some((field) => { + const value = record[field]; + return value !== undefined && value !== null && expected.has(String(value)); + }); + } + + function practiceLayerId(row) { + return String(row && (row.recordId || row.id || row.sessionId) || ''); + } + async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { + // The real kernel reads all three entity stores in one readonly + // IndexedDB transaction. Keep the fallback for deliberately minimal + // embedders and unit-test kernels that only expose the original methods. + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); + } + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + const summaries = ids === null + ? await kernel.listEntities('practiceSummaries', { withMeta }) + : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); + const targetIds = summaries.map(practiceLayerId).filter(Boolean); + const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) + .then((rows) => rows.filter(Boolean)); + return { + practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], + practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], + practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] + }; + } + async function practiceLayers(recordId, withMeta = false) { + const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + return { + summary: find('practiceSummaries'), + detail: find('practiceDetails'), + annotations: find('practiceAnnotations') + }; + } + async function suiteChildRecordIds(command, aggregateRecordId) { + const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); + const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); + if (sessionIds.size) { + const summaries = await kernel.listEntities('practiceSummaries'); + for (const summary of summaries) { + if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); + } + } + ids.delete(String(aggregateRecordId)); + return ids; + } + function entityRevision(row) { return row ? Number(row.revision) : 0; } + function practiceUpserts(recordId, layers, existing = {}) { + return [ + { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) }, + { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) }, + { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) } + ]; + } + async function joinedPractice(recordId, projection, snapshot = null) { + const mode = String(projection || 'full').toLowerCase(); + const layers = snapshot || await practiceProjectionSnapshot([recordId], false, + mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : null)); + const summary = asArray(layers.practiceSummaries) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (!summary) return null; + if (mode === 'light' || mode === 'summary') return clone(summary); + const detail = asArray(layers.practiceDetails) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); + const annotations = asArray(layers.practiceAnnotations) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + return joinPracticeRecord(summary, detail, annotations, mode); + } + function practiceSummaryTime(summary) { + const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); + return Number.isFinite(time) ? time : 0; + } + function isReadingInsightSummary(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => practiceType(entry) === 'reading'); + } + return practiceType(asObject(summary)) === 'reading'; + } + function needsQuestionTypeInsightBackfill(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => + practiceType(entry) === 'reading' + && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); + } + return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + } + const practice = Object.freeze({ + async list(options = {}) { + await ready; + const projection = String(options.projection || 'full').toLowerCase(); + const snapshot = await practiceProjectionSnapshot(null, false, + projection === 'light' || projection === 'summary' + ? ['practiceSummaries'] + : null); + const summaries = asArray(snapshot.practiceSummaries); + if (projection === 'light' || projection === 'summary') return summaries; + return (await Promise.all(summaries + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) + .filter(Boolean); + }, + async listInsights(options = {}) { + await ready; + const requestedLimit = Number(options.limit); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 50) + : 10; + const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); + const summaries = asArray(snapshot.practiceSummaries) + .filter(isReadingInsightSummary) + .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) + .slice(0, limit); + return summaries.map((summary) => { + if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); + const detail = asArray(snapshot.practiceDetails) + .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; + return detail + ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) + : clone(summary); + }); + }, + async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, + async completeAttempt(command) { + await ready; + const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command; + const mutation = mutationOptions(command, 'practice-complete', source); + const recordInput = await practiceRecordWithLibraryProvenance(source, command); + if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId); + const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities( + practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation)); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async finalizeSuite(command) { + await ready; assertObject(command, 'finalizeSuite command is required'); + const mutation = mutationOptions(command, 'practice-suite', command); + const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); + if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); + const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command, async () => { + const existing = await practiceLayers(recordId, true); + const children = await suiteChildRecordIds(command, recordId); + const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); + return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); + }); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async updateAnnotations(command) { + await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || ''); + return retryMergeConflict(command, async () => { + const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`); + if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`); + const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data))); + const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default'); + if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) { + if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`); + annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) }); + } else { + if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`); + annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) }); + Object.assign(annotations, clone(asObject(command.patch))); + } + return kernel.mutateEntities([{ + type: 'upsert', + store: 'practiceAnnotations', + recordId, + data: annotations, + expectedRevision: entityRevision(current.annotations) + }], mutationOptions(command, 'practice-annotations', command)); + }); + }, + async delete(command) { + await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required'); + const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId })); + return Object.assign({}, receipt, { deletedCount: 1 }); + }, + async deleteMany(command) { + await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean))); + if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id]))); + if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); + }, + async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, + projectLight, + projectDetail + }); + + const settings = Object.freeze({ + async getAll() { await ready; return kernel.read('settings.values'); }, + async patch(values, options = {}) { + await ready; assertObject(values, 'settings.patch requires an object'); + const mutation = optionsMutationOptions(options, 'settings-patch', values); + return retryMergeConflict(options, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + }); + }, + async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); } + }); + + const library = Object.freeze({ + async listConfigurations() { await ready; return kernel.read('library.configurations'); }, + async getActive() { await ready; return kernel.read('library.activeConfigurationId'); }, + async getIndex(configurationId) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + if (id === null) return []; + const indexes = await kernel.read('library.importedIndexes'); + return asArray(indexes[id]); + }, + async updateConfiguration(configuration, options = {}) { + await ready; assertObject(configuration, 'library.updateConfiguration requires an object'); + const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId'])); + const current = await kernel.read('library.configurations', { withMeta: true }); + const configs = asArray(current.data); + const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id); + const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id }); + if (index >= 0) configs[index] = next; else configs.push(next); + return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration)); + }, + async activate(configurationId, options = {}) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + const current = await kernel.read('library.activeConfigurationId', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id })); + }, + async import(command) { + await ready; assertObject(command, 'library.import requires a command'); + const id = importedLibraryId(command.id || command.configurationId || randomId('library')); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id); + configs.push(Object.assign({}, asObject(command.configuration), { id, key: id })); + const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) }); + return kernel.mutate([ + { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ], mutationOptions(command, 'library-import', command)); + }, + async remove(configurationId, options = {}) { + await ready; const id = importedLibraryId(configurationId); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true }); + const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id]; + const changes = [ + { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ]; + if (String(activeMeta.data || '') === id) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 }); + } + return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id })); + }, + async resolveIndex() { + await ready; + const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]); + return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]); + } + }); + + function recoveryKey(kind) { + const key = RECOVERY_KEYS[String(kind || '')]; + if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`); + return key; + } + // Recovery document TTL is an AppData domain rule, not a catalog policy field. + const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000; + function recoveryTimestamp(item) { + for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) { + const parsed = Date.parse(item && item[field]); + if (Number.isFinite(parsed)) return parsed; + } + return null; + } + async function pruneRecoveryKey(logicalKey) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await kernel.read(logicalKey, { withMeta: true }); + const items = asArray(current.data); + const cutoff = Date.now() - RECOVERY_TTL_MS; + const retained = items.filter((item) => { + const timestamp = recoveryTimestamp(item); + return timestamp === null || timestamp > cutoff; + }); + if (retained.length === items.length) return items; + try { + await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], { + operationId: randomId('recovery-ttl') + }); + return retained; + } catch (error) { + if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error; + } + } + return kernel.read(logicalKey); + } + async function cleanupExpiredRecovery() { + for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey); + } + const windowSession = Object.freeze({ + save(name, value) { + if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable'); + const logicalName = String(name || 'default'); + const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) }; + global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload)); + return true; + }, + get(name) { + if (!global.sessionStorage) return null; + const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + if (!raw) return null; + const payload = JSON.parse(raw); + return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null; + }, + discard(name) { + if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + return true; + } + }); + + const recoveryMutationTails = new Map(); + function enqueueRecoveryMutation(logicalKey, task) { + const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve(); + const result = previous.then(task, task); + recoveryMutationTails.set(logicalKey, result.catch(() => undefined)); + return result; + } + + async function readRecovery(kind, id) { + await ready; + const items = await pruneRecoveryKey(recoveryKey(kind)); + return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null; + } + async function saveRecovery(kind, value, options = {}) { + await ready; assertObject(value, `recovery ${kind} value must be an object`); + const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value); + const key = recoveryKey(kind); + const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId); + const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() }); + const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + if (index >= 0) current.items[index] = item; else current.items.push(item); + return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation); + })); + const committedItem = (await kernel.read(key)) + .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + return Object.assign({}, receipt, { item: clone(committedItem || item) }); + } + async function discardRecovery(kind, id, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id)); + return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation); + })); + } + async function clearRecovery(kind, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) { + throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision }); + } + return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation); + })); + } + async function clearAllRecovery(options = {}) { + const results = {}; + for (const kind of Object.keys(RECOVERY_KEYS)) { + results[kind] = await clearRecovery(kind, options); + } + return results; + } + const recovery = Object.freeze({ + windowSession, + async clear(options = {}) { return clearAllRecovery(options); }, + async listActiveSessions() { return readRecovery('activeSession'); }, + async getActiveSession(id) { return readRecovery('activeSession', id); }, + async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); }, + async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async listDrafts() { return readRecovery('draft'); }, + async getDraft(id) { return readRecovery('draft', id); }, + async saveDraft(value, options) { return saveRecovery('draft', value, options); }, + async discardDraft(id, options) { return discardRecovery('draft', id, options); }, + async listInterrupted() { return readRecovery('interrupted'); }, + async getInterrupted(id) { return readRecovery('interrupted', id); }, + async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); }, + async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); }, + async listRejectedCompletions() { return readRecovery('rejectedCompletion'); }, + async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); }, + async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); }, + async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); } + }); + + function isImportableEntry(entry) { + return entry + && entry.classification !== 'system' + && entry.classification !== 'session' + && entry.import !== 'ignore'; + } + + function isPlainImportObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function isV2SnapshotShape(parsed) { + return isPlainImportObject(parsed) + && parsed.format === 'ielts-atlas-data-v2' + && isPlainImportObject(parsed.envelopes) + && isPlainImportObject(parsed.entities); + } + + const POISONED_V2_WRAPPER_ALIASES = Object.freeze({ + 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']), + 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']), + 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state']) + }); + const LIBRARY_IMPORT_KEYS = Object.freeze([ + 'library.configurations', + 'library.importedIndexes', + 'library.activeConfigurationId' + ]); + + function parseLegacyImportValue(value) { + if (typeof internals.parseLegacyValue !== 'function') { + throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); + } + return internals.parseLegacyValue(value); + } + + function decodePoisonedDocument(logicalKey, wrapped) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; + if (!aliases || !isPlainImportObject(wrapped) + || !Object.prototype.hasOwnProperty.call(wrapped, 'key') + || !Object.prototype.hasOwnProperty.call(wrapped, 'value') + || !aliases.includes(String(wrapped.key))) { + return { matched: false, value: null }; + } + const decoded = parseLegacyImportValue(wrapped.value); + if (!isPlainImportObject(decoded)) return { matched: true, value: null }; + const overlay = {}; + for (const [key, value] of Object.entries(wrapped)) { + if (key === 'key' || key === 'value' || key === 'timestamp') continue; + overlay[key] = clone(value); + } + return { + matched: true, + value: Object.assign({}, decoded, overlay) + }; + } + + function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { + if (!envelope || envelope.state !== 'present') return envelope; + const wrapped = envelope.data; + const decoded = decodePoisonedDocument(logicalKey, wrapped); + if (!decoded.matched || !decoded.value) return envelope; + const entry = catalog.get(logicalKey); + const next = internals.makeEnvelope(entry, decoded.value, { + revision: Number(envelope.revision) || 1, + operationId: String(envelope.operationId || randomId('import-repair')), + updatedAt: envelope.updatedAt + }); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + return next; + } + + function validateLibraryImportBundle(envelopes) { + const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (!presentKeys.length) return { valid: true, presentKeys }; + if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { + return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; + } + const values = {}; + for (const key of LIBRARY_IMPORT_KEYS) { + const envelope = envelopes[key]; + values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; + } + const configurations = asArray(values['library.configurations']); + const indexes = asObject(values['library.importedIndexes']); + const configurationIds = new Set(); + for (const configuration of configurations) { + const source = asObject(configuration); + const id = idOf(source, ['id', 'key', 'configId']); + if (!id || !acceptedLibraryId(id) || source.builtIn === true + || !Array.isArray(indexes[id]) || !indexes[id].length) { + return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; + } + configurationIds.add(id); + } + for (const [id, index] of Object.entries(indexes)) { + if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { + return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; + } + } + const activeId = values['library.activeConfigurationId']; + if (activeId !== null && (!acceptedLibraryId(activeId) + || !configurationIds.has(String(activeId)) + || !Array.isArray(indexes[String(activeId)]) + || !indexes[String(activeId)].length)) { + return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; + } + return { valid: true, presentKeys }; + } + + function canonicalizeV2Import(parsed) { + const warnings = []; + const repairedKeys = []; + const ignoredKeys = []; + const envelopes = {}; + for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + if (logicalKey === 'library.activeConfigurationId' + && rawEnvelope && rawEnvelope.state === 'present' + && String(rawEnvelope.data) === '[object Object]') { + ignoredKeys.push(logicalKey); + warnings.push('Skipped poisoned active library id'); + continue; + } + const repairCount = repairedKeys.length; + const repaired = repairPoisonedImportEnvelope( + logicalKey, + clone(rawEnvelope), + repairedKeys, + warnings + ); + const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; + const isLegacyRowWrapper = isPlainImportObject(rawData) + && Object.prototype.hasOwnProperty.call(rawData, 'key') + && Object.prototype.hasOwnProperty.call(rawData, 'value') + && String(rawData.key || '').startsWith('exam_system_'); + if (isLegacyRowWrapper && repairedKeys.length === repairCount) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + envelopes[logicalKey] = repaired; + } + const library = parsed.scope === 'full' + ? validateLibraryImportBundle(envelopes) + : { valid: true, presentKeys: [] }; + if (!library.valid) { + for (const logicalKey of library.presentKeys) { + delete envelopes[logicalKey]; + ignoredKeys.push(logicalKey); + } + warnings.push(`Skipped unsafe library data: ${library.reason}`); + } + const exportableKeys = catalog.list() + .filter((entry) => entry.export === true && isImportableEntry(entry)) + .map((entry) => entry.logicalKey); + const missingKeys = parsed.scope === 'full' + ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) + : []; + if (parsed.scope === 'full' && missingKeys.length) { + warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); + } + return { + envelopes, + warnings, + repairedKeys, + ignoredKeys, + missingKeys, + declaredScope: parsed.scope, + effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, + trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length + ? 'trusted-full' + : 'degraded-partial' + }; + } + + function resolveImportReplaceFlags(options = {}) { + const source = asObject(options); + const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase(); + const replaceAll = source.replace === true; + return { + replaceDocuments: replaceAll, + // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge. + replacePractice: replaceAll || practiceMode === 'replace' + }; + } + + function pickFirstRecordArray(candidates) { + for (const candidate of asArray(candidates)) { + if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) { + return { source: candidate.source, records: candidate.records }; + } + } + return null; + } + + /** + * Historical v1 export shapes (opensource / pre-AppData-v2): + * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats } + * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? } + * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*) + * - bare array of records, or { records: [...] } + * Recognition only — no dual backend and no local store migration. + */ + function extractLegacyPracticeRecords(payload) { + const sources = []; + const add = (source, records) => { + if (Array.isArray(records) && records.some(isPlainImportObject)) { + sources.push({ source, records }); + } + }; + + if (Array.isArray(payload)) { + add('(root array)', payload); + } else if (isPlainImportObject(payload)) { + const preferred = pickFirstRecordArray([ + { source: 'practice_records', records: payload.practice_records }, + { source: 'practiceRecords', records: payload.practiceRecords }, + { source: 'records', records: payload.records } + ]); + if (preferred) add(preferred.source, preferred.records); + + const data = isPlainImportObject(payload.data) ? payload.data : null; + if (data) { + const nested = pickFirstRecordArray([ + { source: 'data.practice_records', records: data.practice_records }, + { source: 'data.practiceRecords', records: data.practiceRecords } + ]); + if (nested) add(nested.source, nested.records); + else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data); + else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data); + if (isPlainImportObject(data.exam_system_practice_records)) { + add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); + } + } + if (isPlainImportObject(payload.exam_system_practice_records)) { + add('exam_system_practice_records.data', payload.exam_system_practice_records.data); + } + } + + const seen = new Set(); + const records = []; + for (const entry of sources) { + for (const item of asArray(entry.records)) { + if (!isPlainImportObject(item)) continue; + const identity = idOf(item, ['id', 'recordId', 'sessionId']); + if (identity) { + if (seen.has(identity)) continue; + seen.add(identity); + } + records.push(item); + } + } + return { + records, + sources: sources.map((entry) => entry.source) + }; + } + + function entityRowFromLayer(recordId, data, operationId) { + const payload = jsonValue(data, 'import practice entity'); + return { + recordId: String(recordId), + revision: 1, + operationId: String(operationId || `import-${recordId}`), + updatedAt: nowIso(), + data: payload, + checksum: checksum(payload) + }; + } + + function convertLegacyPracticeImport(payload) { + const extracted = extractLegacyPracticeRecords(payload); + if (!extracted.records.length) { + throw new AppDataError( + 'VALIDATION', + 'Import file is neither a v2 snapshot nor a recognizable v1 practice export' + ); + } + + const entities = { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + }; + const warnings = []; + let skipped = 0; + + for (const raw of extracted.records) { + try { + const layers = splitPracticeRecord(raw); + const recordId = layers.summary.id; + const operationId = `import-v1-${recordId}`; + entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId)); + entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId)); + entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId)); + } catch (error) { + skipped += 1; + warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`); + } + } + + if (!entities.practiceSummaries.length) { + throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized'); + } + + const accepted = entities.practiceSummaries.length; + return { + format: 'v1', + scope: 'partial', + envelopes: {}, + entities, + checksum: null, + warnings, + practiceSummary: { + accepted, + importedCount: accepted, + skippedCount: skipped, + sources: extracted.sources.slice() + } + }; + } + + function parseImportPayload(payload) { + let parsed; + try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); } + catch (error) { + if (error instanceof AppDataError) throw error; + throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message }); + } + + // Bare record arrays are a historical import convenience (UI file pickers). + if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed); + if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object'); + + if (isV2SnapshotShape(parsed)) { + if (Number(parsed.schemaVersion) !== Number(catalog.version)) { + throw new AppDataError('VALIDATION', 'Import schema version mismatch'); + } + if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) { + throw new AppDataError('VALIDATION', 'Import checksum mismatch'); + } + if (parsed.scope !== 'full' && parsed.scope !== 'partial') { + throw new AppDataError('VALIDATION', 'Import scope must be full or partial'); + } + const scope = parsed.scope; + for (const [store, rows] of Object.entries(parsed.entities)) { + if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) { + throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`); + } + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) { + throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + } + } + } + if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) { + throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer'); + } + const canonical = canonicalizeV2Import(parsed); + return { + format: 'v2', + scope: canonical.effectiveScope, + declaredScope: canonical.declaredScope, + envelopes: canonical.envelopes, + entities: parsed.entities, + checksum: parsed.checksum, + warnings: canonical.warnings, + practiceSummary: null, + repairedKeys: canonical.repairedKeys, + ignoredKeys: canonical.ignoredKeys, + missingKeys: canonical.missingKeys, + trust: canonical.trust + }; + } + + // Explicit but malformed v2 claims must not fall through to legacy parsers. + if (parsed.format === 'ielts-atlas-data-v2') { + throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported'); + } + + return convertLegacyPracticeImport(parsed); + } + + function collectionIdentityFields(logicalKey) { + if (logicalKey === 'library.configurations') return ['id', 'key', 'configId']; + if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId']; + if (logicalKey === 'backups.entries') return ['id']; + if (logicalKey === 'vocab.words') return ['id', 'word', 'key']; + if (logicalKey === 'goals.items') return ['id', 'goalId']; + return ['id', 'sessionId', 'recordId']; + } + + function collectionIdentity(logicalKey, value) { + const identity = idOf(value, collectionIdentityFields(logicalKey)); + return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity; + } + + function mergeCollection(existing, incoming, logicalKey) { + const result = asArray(existing).map((item) => clone(item)); + const positions = new Map(); + result.forEach((item, index) => { + const identity = collectionIdentity(logicalKey, item); + if (identity) positions.set(identity, index); + }); + for (const rawItem of asArray(incoming)) { + const item = jsonValue(rawItem, `${logicalKey} item`); + const identity = collectionIdentity(logicalKey, item); + if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`); + if (positions.has(identity)) result[positions.get(identity)] = item; + else { + positions.set(identity, result.length); + result.push(item); + } + } + return result; + } + + function mergeImportValue(entry, existing, incoming) { + const policy = entry.import; + if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey); + if (policy === 'patch') { + if (Array.isArray(existing) || Array.isArray(incoming)) { + // Array-shaped keys should use merge-by-id; treat accidental patch as replace. + return clone(incoming); + } + return Object.assign({}, asObject(existing), asObject(incoming)); + } + if (policy === 'replace') return clone(incoming); + throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`); + } + + async function currentEntitySnapshot() { + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(null, { withMeta: true }); + } + const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); + const result = {}; + for (const store of PRACTICE_ENTITY_STORES) { + if (store === 'practiceSummaries') result[store] = summaries; + else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean); + } + return result; + } + function practiceEntityIds(rows) { + return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean)); + } + function assertPracticeEntitySetsMatch(entities, message) { + const expected = practiceEntityIds(entities.practiceSummaries); + for (const store of PRACTICE_ENTITY_STORES.slice(1)) { + const actual = practiceEntityIds(entities[store]); + if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) { + throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', { + counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size])) + }); + } + } + } + async function createImportPlan(parsed, options = {}) { + const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options); + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} }; + const revisionToken = { documents: {}, entities: {} }; + const keys = []; const clearedKeys = []; + const warnings = asArray(parsed.warnings).map(String); + for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue; + if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`); + if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) { + warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); + continue; + } + const currentRead = await kernel.read(logicalKey, { withMeta: true }); + const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; + const currentEnvelope = currentRead && currentRead.envelope; + revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + let next = envelope; + if (!replaceDocuments && envelope.state === 'present') { + next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + } + snapshot.envelopes[logicalKey] = next; + keys.push(logicalKey); + if (next.state === 'cleared') clearedKeys.push(logicalKey); + } + + // Any successful practice import installs all three stores together. Merge + // may update a subset only when the final recordId sets remain identical. + const sourceStores = Object.keys(asObject(parsed.entities)); + let practiceExistingCount = null; + let practiceIncomingCount = null; + if (sourceStores.length) { + if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) { + throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations'); + } + const current = await currentEntitySnapshot(); + revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries( + asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0]) + )])); + practiceExistingCount = asArray(current.practiceSummaries).length; + practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length; + const existing = replacePractice + ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []])) + : current; + for (const store of PRACTICE_ENTITY_STORES) { + const rows = asArray(existing[store]).map(clone); + const positions = new Map(rows.map((row, index) => [String(row.recordId), index])); + for (const row of asArray(parsed.entities[store])) { + if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + const index = positions.get(String(row.recordId)); + if (index === undefined) { + positions.set(String(row.recordId), rows.length); + rows.push(clone(row)); + } else rows[index] = clone(row); + } + snapshot.entities[store] = rows; + } + assertPracticeEntitySetsMatch(snapshot.entities); + } + + snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities }); + const practiceSummary = parsed.practiceSummary + ? clone(parsed.practiceSummary) + : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries') + ? { + accepted: Number(practiceIncomingCount) || 0, + importedCount: Number(practiceIncomingCount) || 0, + skippedCount: 0, + existingCount: Number(practiceExistingCount) || 0, + incomingCount: Number(practiceIncomingCount) || 0, + finalCount: asArray(snapshot.entities.practiceSummaries).length, + removedCount: Math.max(0, (Number(practiceExistingCount) || 0) + - asArray(snapshot.entities.practiceSummaries).length) + } + : null); + if (practiceSummary && practiceSummary.existingCount === undefined) { + practiceSummary.existingCount = Number(practiceExistingCount) || 0; + practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0; + practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length; + practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount); + } + const destructive = clearedKeys.length > 0 + || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0); + return { + snapshot, + keys, + clearedKeys, + warnings, + practiceSummary, + destructive, + resetJournal: replaceDocuments && replacePractice, + revisionToken, + diagnostics: { + format: parsed.format, + replaceDocuments, + replacePractice, + declaredScope: parsed.declaredScope || parsed.scope, + effectiveScope: parsed.scope, + trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'), + missingKeys: clone(parsed.missingKeys || []), + repairedKeys: clone(parsed.repairedKeys || []), + ignoredKeys: clone(parsed.ignoredKeys || []) + } + }; + } + async function createRestoreSnapshot(backup) { + const parsed = parseImportPayload(asObject(backup && backup.data)); + if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups'); + if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch'); + return (await createImportPlan(parsed, { replace: true })).snapshot; + } + + const backups = Object.freeze({ + onDataCommitted(listener) { return kernel.onCommitted(listener); }, + async getSettings() { await ready; return kernel.read('backups.settings'); }, + async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); }, + async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); }, + async getImportHistory() { await ready; return kernel.read('backups.importHistory'); }, + async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); }, + async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); }, + async create(options = {}) { + await ready; const current = await readCollectionMeta('backups.entries'); + const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' }); + const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup')); + const existing = current.items.find((item) => String(item.id) === String(backupId)); + if (existing) { + if (String(existing.operationId || '') === String(mutation.operationId) + && String(existing.type || 'manual') === String(options.type || 'manual')) { + return clone(existing); + } + throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, { + backupId: String(backupId) + }); + } + const snapshot = await kernel.exportSnapshot(); + const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum }; + current.items.unshift(backup); + current.items = retainBackupEntries(current.items, 20, options.preserveIds); + await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation); + const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId)); + return clone(committed || backup); + }, + async list() { await ready; return kernel.read('backups.entries'); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); }, + async export(options = {}) { + await ready; + if (options.backupId !== undefined && options.backupId !== null) { + const backupId = String(options.backupId); + const stored = asArray(await kernel.read('backups.entries')) + .find((item) => String(item && item.id) === backupId); + if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`); + const portable = jsonValue(stored, 'stored backup export'); + if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) { + throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`); + } + return portable; + } + const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null; + const logicalKeys = domains + ? catalog.list() + .filter((entry) => domains.has(entry.owner) && entry.export === true) + .map((entry) => entry.logicalKey) + : null; + const entityStores = !domains || domains.has('practice') + ? undefined + : []; + return kernel.exportSnapshot(Object.assign( + logicalKeys ? { logicalKeys } : {}, + entityStores ? { entityStores } : {} + )); + }, + async previewImport(payload, options = {}) { + await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan'); + const cutoff = Date.now() - (30 * 60 * 1000); + for (const [id, existing] of importPlans) { + if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id); + } + const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) }; + importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive }; + }, + async commitImport(planId, options = {}) { + await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`); + if (plan.destructive && options.confirmDestructive !== true) { + throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation'); + } + const mutation = optionsMutationOptions(options, 'import-commit', { + planId: plan.id, + signature: plan.signature + }, { warnings: plan.warnings }); + const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, { + resetJournal: plan.resetJournal === true, + expectedRevisionToken: plan.revisionToken + })); + importPlans.delete(String(planId)); + return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) }); + }, + async restore(id, options = {}) { + await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id)); + if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`); + const snapshot = await createRestoreSnapshot(backup); + const restoreMutation = optionsMutationOptions(options, 'backup-restore', { + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }, { resetJournal: true }); + const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`; + const preRestoreBackupId = `pre_restore_${checksum({ + operationId: restoreMutation.operationId, + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }).replace(/[^a-z0-9]/gi, '')}`; + const preRestoreBackup = await backups.create({ + id: preRestoreBackupId, + operationId: preRestoreOperationId, + type: 'pre-restore', + preserveIds: [String(id)] + }); + const receipt = await kernel.installSnapshot(snapshot, restoreMutation); + return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id }); + } + }); + + let vocabMutationTail = Promise.resolve(); + function enqueueVocabMutation(task) { + const result = vocabMutationTail.then(task, task); + vocabMutationTail = result.catch(() => undefined); + return result; + } + function retryVocabMutation(options, task) { + return enqueueVocabMutation(() => retryMergeConflict(options, task)); + } + + const vocab = Object.freeze({ + async listWords() { await ready; return kernel.read('vocab.words'); }, + async saveWords(words, options = {}) { + await ready; assertArray(words, 'vocab.saveWords requires an array'); + const mutation = optionsMutationOptions(options, 'vocab-words', words); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.words', { withMeta: true }); + return mutateAndProject([{ + logicalKey: 'vocab.words', + data: words, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async getConfig() { await ready; return kernel.read('vocab.userConfig'); }, + async setConfig(config, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'vocab-config', config); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: asObject(config), + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async patchConfig(patch, options = {}) { + await ready; assertObject(patch, 'vocab.patchConfig requires an object'); + const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), clone(patch)); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); }, + async listCollections() { await ready; return kernel.read('vocab.lists'); }, + async saveCollection(id, value, options = {}) { + await ready; + const collectionId = String(id); + const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async saveCollections(values, options = {}) { + await ready; + assertObject(values, 'vocab.saveCollections requires an object'); + const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)])); + const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), upserts); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async upsertCollectionWord(collectionId, word, options = {}) { + await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word'); + const id = String(collectionId || ''); + if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required'); + const identity = String(word.word || word.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + const existing = collections[id]; + const list = existing && typeof existing === 'object' && !Array.isArray(existing) + ? Object.assign({}, clone(existing), { words: asArray(existing.words) }) + : { id, words: asArray(existing) }; + const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity); + const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() }); + if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt; + if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); + list.updatedAt = nowIso(); + collections[id] = list; + const receipt = await mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(nextWord) }); + }); + }, + async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; }, + async replaceListWords(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceListWords requires a command'); + const id = String(command.listId || 'default'); const words = asArray(command.words); + if (id === 'default') return this.saveWords(words, options); + const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async mergeListWords(command, options = {}) { + await ready; + assertObject(command, 'vocab.mergeListWords requires a command'); + const listId = String(command.listId || 'default'); + const incoming = asArray(command.words); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions(options, 'vocab-words-merge', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data)); + const storedList = listId === 'default' + ? asArray(current.data) + : (function readStoredCollection() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? asArray(collection.words) + : asArray(collection); + }()); + const merged = storedList.map((word) => clone(word)); + const positions = new Map(); + merged.forEach((word, index) => { + const identity = String(word && (word.word || word.id) || '').trim().toLowerCase(); + if (identity) positions.set(identity, index); + }); + let addedCount = 0; + let updatedCount = 0; + for (const rawWord of incoming) { + assertObject(rawWord, 'vocab.mergeListWords entries must be objects'); + const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + if (!positions.has(identity)) { + positions.set(identity, merged.length); + merged.push(clone(rawWord)); + addedCount += 1; + continue; + } + const index = positions.get(identity); + const existing = asObject(merged[index]); + const patch = {}; + if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim(); + if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim(); + if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq; + merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() }); + updatedCount += 1; + } + const data = listId === 'default' + ? merged + : Object.assign({}, collections, { + [listId]: Object.assign( + {}, + (function collectionBaseForWrite() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? clone(collection) + : {}; + }()), + { id: listId, words: merged, updatedAt: nowIso() } + ) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount }); + }); + }, + async patchWord(command, options = {}) { + await ready; assertObject(command, 'vocab.patchWord requires a command'); + const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || ''); + if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required'); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions( + Object.assign({}, options, { operationId: command.operationId || options.operationId }), + 'vocab-word-patch', + command + ); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : asObject(current.data); + const list = listId === 'default' + ? asArray(current.data) + : asArray(asObject(collections[listId]).words); + const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId); + if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`); + const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() }); + const next = list.slice(); next[index] = updated; + const data = listId === 'default' + ? next + : Object.assign({}, collections, { + [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(updated) }); + }); + }, + async replaceProgress(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceProgress requires a command'); + const listId = String(command.listId || 'default'); const words = asArray(command.words); + const mutation = optionsMutationOptions(options, 'vocab-progress', command); + return retryVocabMutation(options, async () => { + const configMeta = await kernel.read('vocab.userConfig', { withMeta: true }); + const changes = [{ + logicalKey: 'vocab.userConfig', + data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }), + expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0 + }]; + if (listId === 'default') { + const wordsMeta = await kernel.read('vocab.words', { withMeta: true }); + changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 }); + } else { + const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data)); + lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); + changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); + } + return mutateAndProject(changes, mutation); + }); + } + }); + + async function readPreferences() { await ready; return kernel.read('preferences.values'); } + let preferenceMutationTail = Promise.resolve(); + function enqueuePreferenceMutation(task) { + const result = preferenceMutationTail.then(task, task); + preferenceMutationTail = result.catch(() => undefined); + return result; + } + async function writePreference(field, value, options = {}) { + const mutation = optionsMutationOptions(options, 'preference-set', { field, value }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [field]: clone(value) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + async function patchPreference(field, patch, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const values = asObject(current.data); + const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + const preferences = Object.freeze({ + async getAll() { return readPreferences(); }, + async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); }, + async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); }, + async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); }, + async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); }, + async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); } + ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); }, + async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); }, + async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); }, + async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); }, + async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); }, + async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); }, + async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); }, + async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); } + }); + + const goals = Object.freeze({ + async list() { await ready; return kernel.read('goals.items'); }, + async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); } + }); + + function deliveryTimestamp(value) { + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN; + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function mergeDeliveryAcknowledgements(current, incoming) { + const merged = Object.assign({}, asObject(current)); + for (const [id, value] of Object.entries(asObject(incoming))) { + const key = String(id).trim(); + if (!key) continue; + const previous = deliveryTimestamp(merged[key]); + const next = deliveryTimestamp(value); + if (!hasOwn(merged, key) || (next && (!previous || next < previous))) { + merged[key] = next; + } else if (previous) { + merged[key] = previous; + } else { + merged[key] = null; + } + } + return merged; + } + + const achievements = Object.freeze({ + async getAll() { + await ready; + const progress = await retryMergeConflict({}, async () => { + const [summaries, manual, current] = await Promise.all([ + kernel.listEntities('practiceSummaries'), + kernel.read('achievements.manual'), + kernel.read('achievements.progress', { withMeta: true }) + ]); + const projected = asObject(computeAchievementProgress(summaries, manual, current.data)); + if (checksum(projected) !== checksum(asObject(current.data))) { + await kernel.mutate([{ + logicalKey: 'achievements.progress', + data: projected, + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], { + operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}` + }); + } + return projected; + }, 5); + if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh; + Object.defineProperty(progress, 'fresh', { value: true, enumerable: false }); + return progress; + }, + async retryPending() { return achievements.getAll(); }, + async acknowledgeDelivery(unlocked, options = {}) { + await ready; + assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object'); + const requested = clone(unlocked); + const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested); + return retryMergeConflict({}, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + const settingsValue = asObject(current.data); + const delivery = asObject(settingsValue.achievementDelivery); + const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested); + return kernel.mutate([{ + logicalKey: 'settings.values', + data: Object.assign({}, settingsValue, { + achievementDelivery: { version: 1, acknowledged } + }), + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], mutation); + }, 5); + }, + async getManualState() { await ready; return kernel.read('achievements.manual'); } + }); + + const LEGACY_DOCUMENT_ALIASES = Object.freeze({ + 'settings.values': ['user_settings', 'settings', 'system_settings'], + 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'], + 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'], + 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'], + 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'], + 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'], + 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], + 'achievements.manual': ['achievement_manual_state', 'user_achievements'] + }); + const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ + 'recovery.activeSessions', + 'recovery.drafts', + 'recovery.interrupted', + 'recovery.rejectedCompletions' + ]); + const LEGACY_PREFERENCE_ALIASES = Object.freeze({ + theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', + practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', + ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' + }); + + function legacyRecordArray(value) { + return Array.isArray(value) ? value : asArray(asObject(value).data); + } + function mergeLegacyExternalBackup(legacyValue, externalValue) { + const legacy = Object.assign({}, asObject(legacyValue)); + const external = asObject(externalValue); + const externalRecordKey = ['practice_records', 'practiceRecords'] + .find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (externalRecordKey) { + const records = new Map(); + for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { + const recordId = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); + } + legacy.practice_records = Array.from(records.values()); + } + for (const [target, aliases] of Object.entries({ + user_stats: ['user_stats', 'userStats'], + exam_index: ['exam_index', 'examIndex'], + storage_version: ['storage_version', 'storageVersion'] + })) { + if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (alias) legacy[target] = clone(external[alias]); + } + return legacy; + } + + function acceptedLibraryId(value) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; + try { return importedLibraryId(id); } catch (_) { return null; } + } + + function remapLegacyLibraryId(value) { + return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + } + + async function migrateLegacyLibraryData(legacy) { + const [configMeta, indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.configurations', { withMeta: true }), + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const legacyIdMap = new Map(); + const indexes = {}; + const addLegacyIndex = (oldId, value) => { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; + const index = asArray(value); + if (!index.length) return; + const mappedId = remapLegacyLibraryId(oldId); + legacyIdMap.set(oldId, mappedId); + indexes[mappedId] = clone(index); + }; + + for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); + // Reconciliation is a union. A healthy current v2 value wins for the same + // deterministic library ID, while missing legacy libraries are restored. + for (const [id, value] of Object.entries(asObject(indexMeta.data))) { + if (/^exam_index_/.test(id)) addLegacyIndex(id, value); + else { + const acceptedId = acceptedLibraryId(id); + if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); + } + } + + const configurations = new Map(); + const addConfiguration = (configuration) => { + const source = asObject(configuration); + const oldId = idOf(source, ['id', 'key', 'configId']); + if (!oldId || oldId === 'exam_index') return; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + if (!id || !asArray(indexes[id]).length) return; + configurations.set(id, Object.assign({}, clone(source), { + id, + key: id, + examCount: indexes[id].length + })); + }; + asArray(legacy.exam_index_configurations).forEach(addConfiguration); + asArray(configMeta.data).forEach(addConfiguration); + for (const [oldId, id] of legacyIdMap) { + if (!configurations.has(id)) { + configurations.set(id, { + id, + key: id, + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' + }); + } + } + + const resolveActive = (value) => { + const oldId = value === null || value === undefined ? '' : String(value).trim(); + if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + return id && asArray(indexes[id]).length ? id : null; + }; + const currentRawActive = activeMeta.data; + let activeId = resolveActive(currentRawActive); + const currentIsExplicitDefault = Boolean(activeMeta.envelope) + && (currentRawActive === null || String(currentRawActive || '').trim() === ''); + if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { + activeId = resolveActive(legacy.active_exam_index_key); + } + + const nextConfigurations = Array.from(configurations.values()); + const changes = []; + if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { + changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); + } + if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { + changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); + } + if (activeId !== activeMeta.data) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); + } + if (changes.length) { + await kernel.mutate(changes, { + operationId: `legacy-library-repair-v2-${checksum(changes)}` + }); + } + } + + async function migrateLegacyData() { + // Unit embedders may provide a deliberately minimal kernel bootstrap. + if (typeof internals.readLegacyValues !== 'function') return; + const legacySource = await internals.readLegacyValues(); + if (legacySource && legacySource.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); + const migrationState = asObject(migrationMeta.data); + let externalBackup = null; + if (asObject(migrationState.externalBackupV1).status !== 'consumed' + && typeof internals.readLegacyExternalBackup === 'function') { + try { + externalBackup = await internals.readLegacyExternalBackup(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); + } + } + const legacy = externalBackup + ? mergeLegacyExternalBackup(legacySource, externalBackup) + : legacySource; + if (!legacy || !Object.keys(legacy).length) return; + + const documentMetas = {}; + for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { + documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); + } + const [indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const documentRepairs = []; + const poisonedDocumentKeys = []; + for (const [logicalKey, current] of Object.entries(documentMetas)) { + if (!current.envelope || current.envelope.state !== 'present') continue; + const decoded = decodePoisonedDocument(logicalKey, current.data); + if (!decoded.matched) continue; + poisonedDocumentKeys.push(logicalKey); + const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; + const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + const legacyValue = legacyAlias ? legacy[legacyAlias] : null; + let repairValue = decoded.value; + if (isPlainImportObject(legacyValue)) { + repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); + } + if (!repairValue) continue; + documentRepairs.push({ + logicalKey, + data: repairValue, + expectedRevision: Number(current.envelope.revision) + }); + } + const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => + isPlainImportObject(value) + && Object.prototype.hasOwnProperty.call(value, 'key') + && Object.prototype.hasOwnProperty.call(value, 'value') + && /^exam_system_exam_index_/.test(String(value.key || ''))); + const poisonedActive = String(activeMeta.data) === '[object Object]'; + const libraryPoisoned = poisonedIndex || poisonedActive; + const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; + + await migrateLegacyLibraryData(legacy); + if (documentRepairs.length) { + await kernel.mutate(documentRepairs, { + operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` + }); + } + + const changes = []; + for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { + const currentAudit = asObject(migrationState.v1ToV2); + if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { + continue; + } + const current = await kernel.getEnvelope(logicalKey); + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + if (!alias) continue; + const legacyValue = legacy[alias]; + if (!current) { + changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); + continue; + } + const isBadMigrationWrite = current.state === 'present' + && /^legacy-documents-/.test(String(current.operationId || '')); + if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { + changes.push({ + logicalKey, + data: legacyValue, + expectedRevision: Number(current.revision) + }); + continue; + } + const entry = catalog.get(logicalKey); + if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; + let merged; + try { + merged = mergeImportValue(entry, legacyValue, current.data); + } catch (error) { + if (global.console && console.warn) { + console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); + } + continue; + } + if (checksum(merged) !== checksum(current.data)) { + changes.push({ + logicalKey, + data: merged, + expectedRevision: Number(current.revision) + }); + } + } + if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { + const preferences = {}; + for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) { + if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue; + const path = target.split('.'); let cursor = preferences; + path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; }); + cursor[path[path.length - 1]] = clone(legacy[alias]); + } + if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 }); + } + if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { + changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); + } + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + const recordsValue = legacy.practice_records; + const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); + const operations = []; + let skippedRecords = 0; + const reconciledRecordIds = new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + let canonical; + let parts; + try { + const candidate = jsonValue(record, 'legacy practice record'); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { + candidate.id = `legacy_${index}_${internals.checksum(record)}`; + } + canonical = canonicalizeRecord(candidate); + parts = splitPracticeRecord(canonical); + } catch (error) { + skippedRecords += 1; + if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); + continue; + } + if (reconciledRecordIds.has(canonical.id)) continue; + reconciledRecordIds.add(canonical.id); + // Storage errors are not malformed records. Let them abort this repair so the + // completion marker is not written and the next startup can retry safely. + const existing = await practiceLayers(canonical.id, true); + if (existing.summary && existing.detail && existing.annotations) continue; + operations.push(...practiceUpserts(canonical.id, parts, existing)); + } + if (operations.length) { + await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + } + const migrationAudit = { + version: 4, + status: 'complete', + mode: 'persistent-reconcile', + sourceChecksum: checksum(legacy), + sourceRecordCount: records.length, + skippedRecordCount: skippedRecords + }; + const currentAudit = asObject(migrationState.v1ToV2); + const comparableCurrentAudit = { + version: currentAudit.version, + status: currentAudit.status, + mode: currentAudit.mode, + sourceChecksum: currentAudit.sourceChecksum, + sourceRecordCount: currentAudit.sourceRecordCount, + skippedRecordCount: currentAudit.skippedRecordCount + }; + const externalAudit = externalBackup ? { + version: 1, + status: 'consumed', + sourceChecksum: checksum(externalBackup) + } : null; + const currentExternalAudit = asObject(migrationState.externalBackupV1); + if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) + || (externalAudit && (currentExternalAudit.status !== externalAudit.status + || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { + const nextMigrationState = Object.assign({}, migrationState, { + v1ToV2: Object.assign({}, migrationAudit, { + completedAt: nowIso(), + poisonDetected, + poisonedDocumentKeys, + libraryPoisoned + }) + }); + if (externalAudit) { + nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); + } + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { + operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` + }); + } + } + + const ready = kernel.initialize() + .then(async () => { + // Legacy migration and recovery cleanup are best-effort: a failure here + // (e.g. one malformed v1 record) must not brick the data layer for every + // read that awaits `ready`. Only a genuine backend init failure below is fatal. + try { + await migrateLegacyData(); + } catch (error) { + if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error); + } + try { + await cleanupExpiredRecovery(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error); + } + return true; + }) + .catch((error) => { + if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error); + throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed'); + }); + + const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements }; + Object.defineProperties(AppData, { + ready: { value: ready, enumerable: false }, + status: { value: () => kernel.status(), enumerable: false } + }); + Object.freeze(AppData); + Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false }); + if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) { + throw new Error('AppData v2 failed to close its internal bootstrap channel'); + } + if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) { + throw new Error('AppData v2 failed to close its catalog bootstrap channel'); + } +})(typeof window !== 'undefined' ? window : globalThis); + + /* ===== js/utils/suiteBackGuard.js ===== */ (function initSuiteBackGuard(global) { 'use strict'; @@ -296,7 +4281,20 @@ function compareAnswers(userAnswer, correctAnswer) { const expected = splitAnswerTokens(correctAnswer); - const actual = splitAnswerTokens(userAnswer); + let actual = splitAnswerTokens(userAnswer); + + if ( + expected.length === 1 + && /^[A-Z]$/.test(expected[0]) + && actual.length === 1 + && !/^[A-Z]$/.test(actual[0]) + && typeof userAnswer === 'string' + ) { + const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/); + if (labeledOption) { + actual = [labeledOption[1]]; + } + } if (expected.length === 0 && actual.length === 0) { return null; @@ -442,12 +4440,11 @@ // 错误缓存,用于临时存储检测到的错误 this.errorCache = new Map(); - // 词表存储键配置 - this.storageKeys = { - p1: 'vocab_list_p1_errors', - p4: 'vocab_list_p4_errors', - master: 'vocab_list_master_errors', - custom: 'vocab_list_custom' + this.collectionIds = { + p1: 'spelling-errors-p1', + p4: 'spelling-errors-p4', + master: 'spelling-errors-master', + custom: 'custom' }; this.lexiconCache = null; @@ -465,17 +4462,8 @@ */ async init() { try { - // 等待存储系统就绪 - if (window.storage && window.storage.ready) { - await window.storage.ready; - } - - // 设置命名空间 - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[SpellingErrorCollector] 存储命名空间已设置'); - } - + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; this.initialized = true; console.log('[SpellingErrorCollector] 初始化完成'); } catch (error) { @@ -843,14 +4831,9 @@ try { await this.ensureInitialized(); - const storageKey = this.storageKeys[listId] || listId; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return null; - } - - const list = await window.storage.get(storageKey); + const collectionId = this.collectionIds[listId] || listId; + const collections = await window.AppData.vocab.listCollections(); + const list = collections[collectionId]; const normalizedList = this.normalizeVocabListShape(list, listId, listId); if (normalizedList) { @@ -862,7 +4845,7 @@ return null; } catch (error) { console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error); - return null; + throw error; } } @@ -874,31 +4857,10 @@ async saveVocabList(vocabList) { try { await this.ensureInitialized(); - - if (!vocabList || !vocabList.id) { - console.error('[SpellingErrorCollector] 无效的词表对象'); - return false; - } - - if (!Array.isArray(vocabList.words)) { - vocabList.words = []; - } - - vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; - - // 更新统计信息 - vocabList.stats = vocabList.stats || {}; - vocabList.stats.totalWords = vocabList.words.length; - vocabList.updatedAt = Date.now(); - - const storageKey = this.storageKeys[vocabList.id] || vocabList.id; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return false; - } - - await window.storage.set(storageKey, vocabList); + vocabList = this.prepareVocabList(vocabList); + if (!vocabList) return false; + const collectionId = this.collectionIds[vocabList.id] || vocabList.id; + await window.AppData.vocab.saveCollection(collectionId, vocabList); console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`); return true; @@ -908,6 +4870,19 @@ } } + prepareVocabList(vocabList) { + if (!vocabList || !vocabList.id) { + console.error('[SpellingErrorCollector] 无效的词表对象'); + return null; + } + if (!Array.isArray(vocabList.words)) vocabList.words = []; + const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; + normalized.stats = normalized.stats || {}; + normalized.stats.totalWords = normalized.words.length; + normalized.updatedAt = Date.now(); + return normalized; + } + /** * 获取词表单词数量 * @param {string} listId - 词表ID @@ -919,7 +4894,7 @@ return list ? list.words.length : 0; } catch (error) { console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error); - return 0; + throw error; } } @@ -1489,17 +5464,25 @@ try { await this.ensureInitialized(); await this.ensureCoreLexicon(); - - // 按来源分组错误 const errorsBySource = this.groupErrorsBySource(errors); - - // 保存到各个来源的词表 + const pendingCollections = {}; for (const [source, sourceErrors] of Object.entries(errorsBySource)) { - await this.saveErrorsToList(source, sourceErrors); + let vocabList = await this.loadVocabList(source); + if (!vocabList) vocabList = this.createEmptyList(source, source); + this.mergeErrorsToList(vocabList, sourceErrors); + const prepared = this.prepareVocabList(vocabList); + if (!prepared) throw new Error(`生成 ${source} 错词词表失败`); + pendingCollections[this.collectionIds[source] || source] = prepared; } - // 同步到综合词表 - await this.syncToMasterList(errors); + let masterList = await this.loadVocabList('master'); + if (!masterList) masterList = this.createEmptyList('master', 'all'); + this.mergeErrorsToList(masterList, errors); + const preparedMaster = this.prepareVocabList(masterList); + if (!preparedMaster) throw new Error('生成综合错词词表失败'); + pendingCollections[this.collectionIds.master] = preparedMaster; + + await window.AppData.vocab.saveCollections(pendingCollections); console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`); return true; @@ -1650,7 +5633,9 @@ ); if (vocabList.words.length < originalLength) { - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`); return true; } else { @@ -1680,7 +5665,9 @@ vocabList.words = []; vocabList.updatedAt = Date.now(); - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 清空词表: ${listId}`); return true; @@ -1720,6 +5707,20 @@ } console.log('[PracticeEnhancer] 初始化增强器'); + const HOST_MESSAGE_SOURCE = 'exam_host'; + + function deriveParentOriginFromReferrer() { + try { + if (!document.referrer) return ''; + const parsed = new URL(document.referrer, window.location.href); + // Chromium: file URL.origin is "file://", postMessage event.origin is "null". + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (_) { + return ''; + } + } const DEFAULT_ENHANCER_CONFIG = { autoInitialize: true, @@ -2472,6 +6473,10 @@ sessionId: null, examId: null, // 新增:存储唯一的examId parentWindow: null, + expectedParentOrigin: deriveParentOriginFromReferrer(), + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', answers: {}, correctAnswers: {}, interactions: [], @@ -2588,9 +6593,7 @@ } this.enhancerBaseUrl = this.getEnhancerBaseUrl(); - await this.ensureStorageAvailable(); await this.ensureSpellingErrorCollector(); - await this.prepareStorageNamespace(); // 检测多套题结构 this.isMultiSuite = this.detectMultiSuiteStructure(); @@ -2787,95 +6790,6 @@ }).filter(Boolean); }, - ensureStorageAvailable: async function () { - try { - if (window.storage && typeof window.storage.setNamespace === 'function') { - if (window.storage.ready && typeof window.storage.ready.then === 'function') { - await window.storage.ready; - } - return true; - } - - const tryLoad = async (urls) => { - for (const url of urls) { - if (!url) continue; - try { - console.log('[PracticeEnhancer] 尝试加载存储管理器:', url); - await dependencyLoader.loadScript(url); - if (window.storage && typeof window.storage.setNamespace === 'function') { - if (window.storage.ready && typeof window.storage.ready.then === 'function') { - await window.storage.ready; - } - return true; - } - } catch (error) { - console.warn('[PracticeEnhancer] 存储管理器加载失败:', error); - } - } - return false; - }; - - const baseUrl = this.getEnhancerBaseUrl(); - const baseCandidate = new URL('utils/storage.js', baseUrl).href; - const fallbackUrls = this.buildFallbackUrls([ - '../../../../js/utils/storage.js', - '../../../js/utils/storage.js', - '../../js/utils/storage.js', - '../js/utils/storage.js', - './js/utils/storage.js' - ]); - - const loaded = await tryLoad([baseCandidate, ...fallbackUrls]); - if (loaded) return true; - } catch (error) { - console.warn('[PracticeEnhancer] 加载存储管理器失败:', error); - } - - // 创建简易回退存储,确保流程不中断 - console.warn('[PracticeEnhancer] 使用简易回退存储'); - const fallbackPrefix = 'exam_system_'; - const safeStore = (() => { - try { - return window.localStorage; - } catch (_) { - return null; - } - })(); - - const stubStorage = { - namespace: '', - ready: Promise.resolve(), - setNamespace(ns) { this.namespace = ns ? `${ns}_` : ''; }, - async set(key, value) { - if (!safeStore) return false; - const k = fallbackPrefix + this.namespace + key; - safeStore.setItem(k, JSON.stringify({ value })); - return true; - }, - async get(key) { - if (!safeStore) return null; - const k = fallbackPrefix + this.namespace + key; - const raw = safeStore.getItem(k); - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - return parsed && parsed.value !== undefined ? parsed.value : parsed; - } catch (_) { - return null; - } - }, - async remove(key) { - if (!safeStore) return false; - const k = fallbackPrefix + this.namespace + key; - safeStore.removeItem(k); - return true; - } - }; - - window.storage = stubStorage; - return true; - }, - ensureSpellingErrorCollector: async function () { if (window.spellingErrorCollector) { return true; @@ -2917,42 +6831,6 @@ return loaded; }, - prepareStorageNamespace: async function () { - // 设置共享命名空间 - try { - if (window.storage?.ready) { - await window.storage.ready; - } - - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[PracticeEnhancer] 已设置共享命名空间: exam_system'); - - // 验证命名空间设置是否生效 - setTimeout(async () => { - const testKey = 'namespace_test_enhancer'; - const testValue = 'test_value_enhancer_' + Date.now(); - try { - await window.storage.set(testKey, testValue); - const retrievedValue = await window.storage.get(testKey); - if (retrievedValue === testValue) { - console.log('✅ 增强器命名空间设置验证成功: 存储和读取正常'); - } else { - console.warn('❌ 增强器命名空间设置验证失败: 读取值不匹配'); - } - await window.storage.remove(testKey); - } catch (error) { - console.error('❌ 增强器命名空间设置验证失败', error); - } - }, 1000); - } else { - console.warn('[PracticeEnhancer] 存储管理器未加载或setNamespace方法不可用'); - } - } catch (error) { - console.error('[PracticeEnhancer] 存储初始化失败,跳过命名空间设置', error); - } - }, - cleanup: function () { console.log('[PracticeEnhancer] 清理资源'); if (this.answerCollectionInterval) { @@ -3563,9 +7441,49 @@ } const messageType = String(payload.type).toUpperCase(); const payloadData = payload.data || {}; + if (!event || event.source !== this.parentWindow || payload.source !== HOST_MESSAGE_SOURCE) { + return; + } if (messageType === 'INIT_SESSION' || messageType === 'INIT_EXAM_SESSION') { const initData = payloadData; + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + const declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : ''; + const incomingToken = typeof initData.windowSessionToken === 'string' + ? initData.windowSessionToken.trim() + : ''; + if (!incomingToken) return; + const expectedParentOrigin = this.expectedParentOrigin + && this.expectedParentOrigin !== 'file://' + && !String(this.expectedParentOrigin).startsWith('file:') + ? this.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) { + return; + } + this.parentOrigin = expectedParentOrigin; + this.parentOriginIsOpaque = false; + } else if (window.location.protocol === 'file:') { + const trustedFileOrigin = incomingOrigin === 'null' + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) { + return; + } + this.parentOrigin = 'null'; + this.parentOriginIsOpaque = true; + } else { + const trustedWebOrigin = Boolean(incomingOrigin) + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) { + return; + } + this.parentOrigin = incomingOrigin; + this.parentOriginIsOpaque = false; + } + this.windowSessionToken = incomingToken; this.sessionId = initData.sessionId; this.examId = initData.examId; // 存储 examId if (initData.reviewSessionId) { @@ -3598,6 +7516,17 @@ return; } + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + const incomingToken = typeof payloadData.windowSessionToken === 'string' + ? payloadData.windowSessionToken.trim() + : ''; + const originMatches = this.parentOriginIsOpaque + ? incomingOrigin === 'null' + : Boolean(this.parentOrigin && incomingOrigin === this.parentOrigin); + if (!originMatches || !this.windowSessionToken || incomingToken !== this.windowSessionToken) { + return; + } + if (messageType === 'REPLAY_PRACTICE_RECORD') { this.applyReplayRecord(payloadData || {}); return; @@ -5086,6 +9015,7 @@ // Requirement 9.1: 必须包含的基本字段 examId: `${this.examId}_${suiteId}`, // Requirement 9.2: examId包含套题标识 sessionId: this.sessionId, + suiteSessionId: this.suiteSessionId || null, answers: suiteAnswers, // Requirement 9.3: 答案键使用"套题ID::问题ID"格式 correctAnswers: suiteCorrectAnswers, @@ -6236,29 +10166,57 @@ return null; }, + createSubmissionId: function () { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return `practice-submit-${window.crypto.randomUUID()}`; + } + } catch (_) { + // Fall through to the session-bound fallback. + } + return `practice-submit-${this.sessionId || this.examId || 'session'}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + }, + sendMessage: function (type, data) { if (!this.parentWindow) { console.warn('[PracticeEnhancer] 无父窗口,无法发送消息'); - return; + return false; } if (this.readOnly && type === 'PRACTICE_COMPLETE') { console.info('[PracticeEnhancer] 回顾模式阻止 PRACTICE_COMPLETE 上报'); - return; + return false; } - this.runHooks('beforeSendMessage', type, data); + const payload = data && typeof data === 'object' ? data : {}; + if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') { + payload.sessionId = payload.sessionId || this.sessionId || null; + payload.submissionId = payload.submissionId || this.createSubmissionId(); + } + this.runHooks('beforeSendMessage', type, payload); + const secureData = Object.assign({}, payload, { + windowSessionToken: this.windowSessionToken || null + }); const message = { type: type, - data: data, + data: secureData, source: 'practice_page', timestamp: Date.now() }; try { - this.parentWindow.postMessage(message, '*'); + const targetOrigin = this.parentOrigin && this.parentOrigin !== 'null' + ? this.parentOrigin + : (this.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) { + console.warn('[PracticeEnhancer] 缺少可信父窗口 origin,消息未发送:', type); + return false; + } + this.parentWindow.postMessage(message, targetOrigin); console.log('[PracticeEnhancer] 消息已发送:', type); + return true; } catch (error) { console.error('[PracticeEnhancer] 发送消息失败:', error); + return false; } }, @@ -6326,6 +10284,10 @@ (function markBundleProvided(global) { if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { global.AppLazyLoader.markProvided([ + "js/data/practiceRecordSource.js", + "js/data/v2/dataCatalog.js", + "js/data/v2/dataKernel.js", + "js/data/v2/appData.js", "js/utils/suiteBackGuard.js", "js/utils/answerMatchCore.js", "js/app/spellingErrorCollector.js", diff --git a/js/bundles/practice.bundle.js b/js/bundles/practice.bundle.js index 142c31d4..74ddaaa4 100644 --- a/js/bundles/practice.bundle.js +++ b/js/bundles/practice.bundle.js @@ -64,12 +64,11 @@ // 错误缓存,用于临时存储检测到的错误 this.errorCache = new Map(); - // 词表存储键配置 - this.storageKeys = { - p1: 'vocab_list_p1_errors', - p4: 'vocab_list_p4_errors', - master: 'vocab_list_master_errors', - custom: 'vocab_list_custom' + this.collectionIds = { + p1: 'spelling-errors-p1', + p4: 'spelling-errors-p4', + master: 'spelling-errors-master', + custom: 'custom' }; this.lexiconCache = null; @@ -87,17 +86,8 @@ */ async init() { try { - // 等待存储系统就绪 - if (window.storage && window.storage.ready) { - await window.storage.ready; - } - - // 设置命名空间 - if (window.storage && typeof window.storage.setNamespace === 'function') { - window.storage.setNamespace('exam_system'); - console.log('[SpellingErrorCollector] 存储命名空间已设置'); - } - + if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable'); + await window.AppData.ready; this.initialized = true; console.log('[SpellingErrorCollector] 初始化完成'); } catch (error) { @@ -465,14 +455,9 @@ try { await this.ensureInitialized(); - const storageKey = this.storageKeys[listId] || listId; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return null; - } - - const list = await window.storage.get(storageKey); + const collectionId = this.collectionIds[listId] || listId; + const collections = await window.AppData.vocab.listCollections(); + const list = collections[collectionId]; const normalizedList = this.normalizeVocabListShape(list, listId, listId); if (normalizedList) { @@ -484,7 +469,7 @@ return null; } catch (error) { console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error); - return null; + throw error; } } @@ -496,31 +481,10 @@ async saveVocabList(vocabList) { try { await this.ensureInitialized(); - - if (!vocabList || !vocabList.id) { - console.error('[SpellingErrorCollector] 无效的词表对象'); - return false; - } - - if (!Array.isArray(vocabList.words)) { - vocabList.words = []; - } - - vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; - - // 更新统计信息 - vocabList.stats = vocabList.stats || {}; - vocabList.stats.totalWords = vocabList.words.length; - vocabList.updatedAt = Date.now(); - - const storageKey = this.storageKeys[vocabList.id] || vocabList.id; - - if (!window.storage) { - console.warn('[SpellingErrorCollector] 存储系统不可用'); - return false; - } - - await window.storage.set(storageKey, vocabList); + vocabList = this.prepareVocabList(vocabList); + if (!vocabList) return false; + const collectionId = this.collectionIds[vocabList.id] || vocabList.id; + await window.AppData.vocab.saveCollection(collectionId, vocabList); console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`); return true; @@ -530,6 +494,19 @@ } } + prepareVocabList(vocabList) { + if (!vocabList || !vocabList.id) { + console.error('[SpellingErrorCollector] 无效的词表对象'); + return null; + } + if (!Array.isArray(vocabList.words)) vocabList.words = []; + const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList; + normalized.stats = normalized.stats || {}; + normalized.stats.totalWords = normalized.words.length; + normalized.updatedAt = Date.now(); + return normalized; + } + /** * 获取词表单词数量 * @param {string} listId - 词表ID @@ -541,7 +518,7 @@ return list ? list.words.length : 0; } catch (error) { console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error); - return 0; + throw error; } } @@ -1111,17 +1088,25 @@ try { await this.ensureInitialized(); await this.ensureCoreLexicon(); - - // 按来源分组错误 const errorsBySource = this.groupErrorsBySource(errors); - - // 保存到各个来源的词表 + const pendingCollections = {}; for (const [source, sourceErrors] of Object.entries(errorsBySource)) { - await this.saveErrorsToList(source, sourceErrors); + let vocabList = await this.loadVocabList(source); + if (!vocabList) vocabList = this.createEmptyList(source, source); + this.mergeErrorsToList(vocabList, sourceErrors); + const prepared = this.prepareVocabList(vocabList); + if (!prepared) throw new Error(`生成 ${source} 错词词表失败`); + pendingCollections[this.collectionIds[source] || source] = prepared; } - // 同步到综合词表 - await this.syncToMasterList(errors); + let masterList = await this.loadVocabList('master'); + if (!masterList) masterList = this.createEmptyList('master', 'all'); + this.mergeErrorsToList(masterList, errors); + const preparedMaster = this.prepareVocabList(masterList); + if (!preparedMaster) throw new Error('生成综合错词词表失败'); + pendingCollections[this.collectionIds.master] = preparedMaster; + + await window.AppData.vocab.saveCollections(pendingCollections); console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`); return true; @@ -1272,7 +1257,9 @@ ); if (vocabList.words.length < originalLength) { - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`); return true; } else { @@ -1302,7 +1289,9 @@ vocabList.words = []; vocabList.updatedAt = Date.now(); - await this.saveVocabList(vocabList); + if (!await this.saveVocabList(vocabList)) { + return false; + } console.log(`[SpellingErrorCollector] 清空词表: ${listId}`); return true; @@ -1426,22 +1415,11 @@ class MarkdownExporter { } return comparison; } - constructor() { - this.storage = window.storage; - } - async getPracticeRecordsUnified() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - try { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } catch (error) { - console.warn('[MarkdownExporter] 从 PracticeRecordAPI 获取练习记录失败:', error); - return []; - } - } - - return []; + if (!window.AppData || !window.AppData.practice) throw new Error('AppData.practice is unavailable'); + await window.AppData.ready; + const records = await window.AppData.practice.list({ projection: 'full' }); + return Array.isArray(records) ? records : []; } /** @@ -1494,31 +1472,16 @@ class MarkdownExporter { */ async performExport() { try { - // 尝试从不同的数据源获取记录 let practiceRecords = []; - let examIndex = []; this.updateProgress('正在加载数据...'); // 让出控制权 await new Promise(resolve => setTimeout(resolve, 10)); - // 只使用统一 PracticeRecordAPI 数据 + // 只使用统一 practice domain 数据 practiceRecords = await this.getPracticeRecordsUnified(); - // examIndex 仍从存储/全局读取 - if (this.storage && typeof this.storage.get === 'function') { - try { - const idx = await this.storage.get('exam_index', []); - examIndex = Array.isArray(idx) ? idx : []; - } catch (_) { - examIndex = []; - } - } - if ((!Array.isArray(examIndex) || examIndex.length === 0) && window.examIndex) { - examIndex = Array.isArray(window.examIndex) ? window.examIndex : []; - } - if (practiceRecords.length === 0) { throw new Error('没有练习记录可导出'); } @@ -1552,7 +1515,7 @@ class MarkdownExporter { await new Promise(resolve => setTimeout(resolve, 10)); // 按日期分组记录 - const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords, examIndex); + const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords); // 生成 Markdown 内容 const markdownContent = await this.generateMarkdownContentAsync(recordsByDate); @@ -1608,7 +1571,27 @@ class MarkdownExporter { /** * 异步按日期分组记录 */ - async groupRecordsByDateAsync(practiceRecords, examIndex) { + async resolveExamForRecord(record) { + if (typeof window.resolveExamForPracticeRecord !== 'function') { + return null; + } + return window.resolveExamForPracticeRecord(record); + } + + enhanceRecordForExport(record, exam = null) { + const metadata = record && record.metadata && typeof record.metadata === 'object' + ? record.metadata + : {}; + return { + ...record, + examInfo: exam || {}, + title: record.title || metadata.examTitle || exam?.title || '未知题目', + category: record.category || metadata.category || exam?.category || 'Unknown', + frequency: record.frequency || metadata.frequency || exam?.frequency || 'unknown' + }; + } + + async groupRecordsByDateAsync(practiceRecords) { const grouped = {}; for (let i = 0; i < practiceRecords.length; i++) { @@ -1621,15 +1604,8 @@ class MarkdownExporter { grouped[date] = []; } - // 获取考试信息 - const exam = examIndex.find(e => e.id === record.examId); - const enhancedRecord = { - ...record, - examInfo: exam || {}, - title: exam?.title || record.title || '未知题目', - category: exam?.category || record.category || 'Unknown', - frequency: exam?.frequency || record.frequency || 'unknown' - }; + const exam = await this.resolveExamForRecord(record); + const enhancedRecord = this.enhanceRecordForExport(record, exam); grouped[date].push(enhancedRecord); @@ -1645,7 +1621,7 @@ class MarkdownExporter { /** * 按日期分组记录(同步版本,保持兼容性) */ - groupRecordsByDate(practiceRecords, examIndex) { + groupRecordsByDate(practiceRecords) { const grouped = {}; practiceRecords.forEach(record => { @@ -1656,15 +1632,7 @@ class MarkdownExporter { grouped[date] = []; } - // 获取考试信息 - const exam = examIndex.find(e => e.id === record.examId); - const enhancedRecord = { - ...record, - examInfo: exam || {}, - title: exam?.title || record.title || '未知题目', - category: exam?.category || record.category || 'Unknown', - frequency: exam?.frequency || record.frequency || 'unknown' - }; + const enhancedRecord = this.enhanceRecordForExport(record); grouped[date].push(enhancedRecord); }); @@ -2224,7 +2192,8 @@ class PracticeRecordModal { show(record) { try { - const replayRecord = this.cloneRecord(record); + // 详情展示用 medium;回顾时再按 id 拉 full,避免把注解灌进 modal 缓存。 + const displayRecord = record; let processedRecord = record; if (window.DataConsistencyManager) { @@ -2238,7 +2207,8 @@ class PracticeRecordModal { const modalHtml = this.createModalHtml(processedRecord); this.hide(); - this.currentRecord = replayRecord; + this.currentRecord = this.cloneRecord(displayRecord); + this.currentRecordId = (displayRecord && (displayRecord.id || displayRecord.sessionId)) || null; document.body.insertAdjacentHTML('beforeend', modalHtml); this.modalElement = document.getElementById(this.modalId); @@ -2279,6 +2249,7 @@ class PracticeRecordModal { this.modalElement = null; this.currentRecord = null; this.isVisible = false; + this.currentRecordId = null; } teardownEventListeners() { @@ -2333,8 +2304,10 @@ class PracticeRecordModal { if (replayTrigger) { this.replayTriggerElement = replayTrigger; const launchReplay = async () => { - const replayRecord = this.currentRecord; - if (!replayRecord) { + const recordId = this.currentRecordId + || (this.currentRecord && (this.currentRecord.id || this.currentRecord.sessionId)) + || null; + if (!recordId && !this.currentRecord) { if (typeof window.showMessage === 'function') { window.showMessage('未找到可回放记录', 'error'); } @@ -2349,6 +2322,23 @@ class PracticeRecordModal { closeModal(); try { + // 回顾必须 full:重新按 id 拉取含 highlights/notes 的完整记录。 + // 当前详情多为 medium,full 失败时不得回退 detail(缺注解)。 + let replayRecord = null; + if (window.AppData && recordId) { + replayRecord = await window.AppData.practice.get(recordId, { projection: 'full' }); + } else if (this.currentRecord && ( + Array.isArray(this.currentRecord.highlights) + || Array.isArray(this.currentRecord.notes) + || this.currentRecord.realData + || this.currentRecord.rawData + )) { + // 无 API 时仅允许已是 full 形态的 currentRecord。 + replayRecord = this.currentRecord; + } + if (!replayRecord) { + throw new Error('无法加载完整记录用于回顾'); + } await window.app.openPracticeRecordReplay(replayRecord); } catch (error) { console.error('[PracticeRecordModal] 启动回放失败:', error); @@ -2457,12 +2447,12 @@ class PracticeRecordModal { `; } - prepareRecordForDisplay(record) { + prepareRecordForDisplay(record, examDefinition = null) { if (!record) { return record; } if (window.AnswerComparisonUtils && typeof window.AnswerComparisonUtils.withEnrichedMetadata === 'function') { - return window.AnswerComparisonUtils.withEnrichedMetadata(record); + return window.AnswerComparisonUtils.withEnrichedMetadata(record, examDefinition); } return record; } @@ -2620,7 +2610,10 @@ class PracticeRecordModal { if (record.multiSuite === true && entry.scoreInfo) { const correct = entry.scoreInfo.correct || 0; const total = entry.scoreInfo.total || 0; - const percentage = entry.scoreInfo.percentage || 0; + const rawPercentage = Number(entry.scoreInfo.percentage); + const percentage = Number.isFinite(rawPercentage) + ? (Math.round(rawPercentage * 10) / 10).toFixed(1) + : '0.0'; scoreInfo = `
得分: ${correct}/${total} (${percentage}%)
`; } @@ -3342,36 +3335,26 @@ class PracticeRecordModal { try { const normalise = (value) => (value == null ? '' : String(value)); const targetId = normalise(recordId); - const api = window.PracticeRecordAPI || null; let record = null; - if (api && typeof api.getById === 'function') { - record = await api.getById(targetId); - } - - if (!record && api && typeof api.list === 'function') { - const records = await api.list(); - if (Array.isArray(records)) { - record = records.find(r => normalise(r.id) === targetId) || - records.find(r => normalise(r.sessionId) === targetId); - } - } + record = await window.AppData.practice.get(targetId, { projection: 'full' }); if (!record) { throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728'); } const exporter = new MarkdownExporter(); - const examIndex = await window.storage.get('exam_index', []); - const exam = Array.isArray(examIndex) ? examIndex.find(e => e.id === record.examId) : null; + const exam = typeof window.resolveExamForPracticeRecord === 'function' + ? await window.resolveExamForPracticeRecord(record) + : null; const enrichedRecord = this.prepareRecordForDisplay({ ...record, examInfo: exam || {}, - title: exam?.title || record.title || record.examId || '\u672a\u77e5\u9898\u76ee', - category: exam?.category || record.category || '\u672a\u77e5\u5206\u7c7b', - frequency: exam?.frequency || record.frequency || '\u672a\u77e5\u9891\u7387' - }); + title: record.title || record.metadata?.examTitle || exam?.title || record.examId || '\u672a\u77e5\u9898\u76ee', + category: record.category || record.metadata?.category || exam?.category || '\u672a\u77e5\u5206\u7c7b', + frequency: record.frequency || record.metadata?.frequency || exam?.frequency || '\u672a\u77e5\u9891\u7387' + }, exam); const markdown = exporter.generateRecordMarkdown(enrichedRecord); @@ -3404,20 +3387,9 @@ if (!window.practiceRecordModal.showById) { try { const normalise = (value) => (value == null ? '' : String(value)); const targetId = normalise(recordId); - const api = window.PracticeRecordAPI || null; let record = null; - if (api && typeof api.getById === 'function') { - record = await api.getById(targetId); - } - - if (!record && api && typeof api.list === 'function') { - const records = await api.list(); - if (Array.isArray(records)) { - record = records.find(r => normalise(r.id) === targetId) || - records.find(r => normalise(r.sessionId) === targetId); - } - } + record = await window.AppData.practice.get(targetId, { projection: 'detail' }); if (!record) { throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728'); @@ -3513,7 +3485,7 @@ class PracticeHistoryEnhancer { const hasStandardComponent = window.app?.components?.practiceHistory; const hasBasicStructure = document.querySelector('.practice-history') || document.querySelector('#practice-records') || - window.PracticeRecordAPI; + window.AppData; if (hasStandardComponent || hasBasicStructure) { clearInterval(checkInterval); @@ -3702,30 +3674,11 @@ class PracticeHistoryEnhancer { */ async exportAsJSON() { try { - let practiceRecords = []; - let practiceStats = {}; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - practiceRecords = Array.isArray(records) ? records : []; - } - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - practiceStats = await window.PracticeRecordAPI.readStats(); - } - - if (practiceRecords.length === 0) { + const practiceRecords = await window.AppData.practice.list({ projection: 'light' }); + if (!Array.isArray(practiceRecords) || practiceRecords.length === 0) { throw new Error('没有练习记录可导出'); } - - const data = { - exportDate: new Date().toISOString(), - stats: practiceStats, - user_stats: practiceStats, - userStats: practiceStats, - records: practiceRecords, - practice_records: practiceRecords - }; + const data = await window.AppData.backups.export({ domains: ['practice'] }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -3748,33 +3701,16 @@ class PracticeHistoryEnhancer { } /** - * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌 + * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌。 + * 默认 medium 投影:详情答案层,不含 highlights/notes。 + * 回顾模式请用 fetchRecordById(id, { projection: 'full' })。 */ - async fetchRecordById(recordId) { + async fetchRecordById(recordId, options = {}) { const toIdStr = (v) => v == null ? '' : String(v); const targetIdStr = toIdStr(recordId); + const projection = (options && options.projection) || 'detail'; - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getById === 'function') { - try { - const hit = await window.PracticeRecordAPI.getById(targetIdStr); - if (hit) return hit; - } catch (err) { - console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 获取记录失败:', err); - } - } - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - try { - const records = await window.PracticeRecordAPI.list(); - if (!Array.isArray(records)) return null; - const hit = records.find(r => toIdStr(r.id) === targetIdStr || toIdStr(r.sessionId) === targetIdStr); - if (hit) return hit; - } catch (err) { - console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 列表查找记录失败:', err); - } - } - - return null; + return window.AppData.practice.get(targetIdStr, { projection }); } /** @@ -3822,1991 +3758,112 @@ if (document.readyState === 'loading') { } -/* ===== js/core/scoreStorage.js ===== */ -/** - * ScoreStorage — façade over PracticeRecordAPI / PracticeCore. - * No independent practice write path: saves must go through PracticeRecordAPI. - * Kept for PracticeRecorder UI helpers, stats/list adapters, and backup helpers - * during the post-data-layer transition (see Sprint B/C thinning). - */ -class ScoreStorage { - constructor(options = {}) { - this.repositories = options.repositories || window.dataRepositories; - if (!this.repositories) { - throw new Error('数据仓库未初始化,无法构建 ScoreStorage'); - } - - this.initializationError = null; - this.initializing = true; - - this.storageKeys = { - practiceRecords: 'practice_records', - userStats: 'user_stats', - storageVersion: 'storage_version', - backupData: 'manual_backups' - }; +/* ===== js/utils/answerSanitizer.js ===== */ +(function (global) { + 'use strict'; - this.currentVersion = '0.6.2-fix'; - this.maxRecords = 1000; - this.storage = this.createStorageAdapter(); - if (typeof window !== 'undefined') { - window.scoreStorage = this; + function toStringSafe(value) { + if (value === null || value === undefined) { + return ''; } - - this.ready = this.initialize() - .catch((error) => { - this.initializationError = error; - return Promise.reject(error); - }) - .finally(() => { - this.initializing = false; - }); + return String(value); } - async ensureReady(options = {}) { - const { allowDuringInit = false } = options; - if (this.initializationError) { - throw this.initializationError; - } - if (this.initializing && allowDuringInit) { - return; - } - if (this.ready) { - await this.ready; + function normalizeFromObject(object) { + if (!object || typeof object !== 'object') { + return ''; } - } - - getPracticeRecordAPI(requiredMethods = []) { - const api = window.PracticeRecordAPI; - if (!api || typeof api !== 'object') { - throw new Error('ScoreStorage: PracticeRecordAPI not ready'); + const preferKeys = [ + 'value', + 'answerValue', + 'key', + 'option', + 'heading', + 'word', + 'label', + 'answerLabel', + 'text', + 'answer', + 'content' + ]; + for (var i = 0; i < preferKeys.length; i += 1) { + var key = preferKeys[i]; + if (typeof object[key] === 'string' && object[key].trim()) { + return object[key].trim(); + } } - (Array.isArray(requiredMethods) ? requiredMethods : [requiredMethods]) - .filter(Boolean) - .forEach((methodName) => { - if (typeof api[methodName] !== 'function') { - throw new Error(`ScoreStorage: PracticeRecordAPI.${methodName} not ready`); - } - }); - return api; - } - - async listPracticeRecordsCanonical() { - const api = this.getPracticeRecordAPI(['list']); - const records = await api.list(); - return Array.isArray(records) ? records : []; - } - - async replacePracticeRecordsCanonical(records, options = {}) { - const finalRecords = Array.isArray(records) ? records : []; - const api = this.getPracticeRecordAPI(['replace']); - await api.replace(finalRecords, Object.assign({ - currentVersion: this.currentVersion, - maxRecords: this.maxRecords - }, options || {})); - return true; - } - - normalizePracticeType(rawType) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.normalizePracticeType === 'function') { - return coreContracts.normalizePracticeType(rawType); + if (typeof object.innerText === 'string' && object.innerText.trim()) { + return object.innerText.trim(); } - if (!rawType) return null; - const normalized = String(rawType).toLowerCase(); - if (normalized.includes('listen')) return 'listening'; - if (normalized.includes('read')) return 'reading'; - return null; - } - - inferPracticeType(recordData = {}) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.inferPracticeType === 'function') { - return coreContracts.inferPracticeType(recordData); + if (typeof object.textContent === 'string' && object.textContent.trim()) { + return object.textContent.trim(); } - const metadata = recordData.metadata || {}; - const normalized = this.normalizePracticeType( - recordData.type - || metadata.type - || metadata.examType - || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null) - ); - return normalized || 'reading'; - } - - resolveRecordDate(recordData = {}, now = new Date().toISOString()) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.resolveRecordDate === 'function') { - return coreContracts.resolveRecordDate(recordData, now); - } - const candidates = [ - recordData.metadata?.date, - recordData.date, - recordData.endTime, - recordData.completedAt, - recordData.startTime, - recordData.timestamp, - now - ]; - for (const value of candidates) { - if (!value) continue; - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); + try { + var serialized = JSON.stringify(object); + if (serialized && serialized !== '{}' && serialized !== '[]') { + return serialized; } - } - return now; + } catch (_) {} + return toStringSafe(object); } - inferExamId(recordData = {}) { - if (!recordData || typeof recordData !== 'object') { - return null; + function normalizeValue(value) { + if (value === null || value === undefined) { + return ''; } - if (recordData.examId) { - return recordData.examId; + if (typeof value === 'string') { + var trimmed = value.trim(); + if (/^\[object\s/i.test(trimmed)) { + return ''; + } + return trimmed; } - if (recordData.metadata?.examId) { - return recordData.metadata.examId; + if (typeof value === 'boolean') { + return value ? 'True' : 'False'; } - if (Array.isArray(recordData.suiteEntries)) { - const suiteExam = recordData.suiteEntries.find(entry => entry && entry.examId); - if (suiteExam) { - return suiteExam.examId; - } + if (typeof value === 'number') { + return toStringSafe(value).trim(); } - const recordId = recordData.id; - if (typeof recordId === 'string') { - const match = recordId.match(/^record_([^_]+)_/); - if (match && match[1]) { - return match[1]; - } + if (Array.isArray(value)) { + var normalizedArray = value + .map(function (item) { return normalizeValue(item); }) + .filter(function (item) { return item !== null && item !== undefined && item !== ''; }) + .join(', '); + return normalizedArray.trim(); } - return null; - } - - buildMetadata(recordData = {}, type) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.buildMetadata === 'function') { - return coreContracts.buildMetadata(recordData, type); - } - const metadata = { ...(recordData.metadata || {}) }; - const examId = recordData.examId; - const fallbackTitle = recordData.title || recordData.examTitle || examId || 'Unknown Exam'; - const fallbackCategory = recordData.category || 'Unknown'; - const fallbackFrequency = recordData.frequency || 'unknown'; - - metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle; - metadata.category = metadata.category || fallbackCategory; - metadata.frequency = metadata.frequency || fallbackFrequency; - metadata.type = type; - metadata.examType = metadata.examType || type; - - return metadata; - } - - ensureNumber(value, fallback = 0) { - const num = Number(value); - return Number.isFinite(num) ? num : fallback; + return normalizeFromObject(value).replace(/^\[object\s[^\]]+\]$/i, '').trim(); } - deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) { - const candidates = [ - recordData.totalQuestions, - recordData.questionCount, - recordData.scoreInfo?.total, - recordData.scoreInfo?.totalQuestions, - recordData.realData?.scoreInfo?.totalQuestions, - recordData.realData?.scoreInfo?.total - ]; - for (const candidate of candidates) { - const num = Number(candidate); - if (Number.isFinite(num) && num >= 0) { - return num; - } - } - - if (Array.isArray(recordData.answers)) { - return recordData.answers.length; + function hasMeaningfulValue(value) { + var normalized = normalizeValue(value); + if (!normalized) { + return false; } - if (Array.isArray(recordData.answerList)) { - return recordData.answerList.length; + var lowered = normalized.toLowerCase(); + if (lowered === 'n/a' || lowered === 'no answer' || lowered === '未作答' || lowered === '无' || lowered === 'none') { + return false; } + return true; + } - const detailSources = [ - recordData.answerDetails, - recordData.scoreInfo?.details, - recordData.realData?.scoreInfo?.details - ]; - for (const details of detailSources) { - if (details && typeof details === 'object') { - return Object.keys(details).length; + function normalizeValueList(value) { + var values = Array.isArray(value) ? value : (value === null || value === undefined ? [] : [value]); + var normalized = []; + values.forEach(function (item) { + var text = normalizeValue(item); + if (!hasMeaningfulValue(text)) { + return; } - } - return fallbackLength || 0; + if (!normalized.some(function (existing) { return existing.toLowerCase() === text.toLowerCase(); })) { + normalized.push(text); + } + }); + return normalized; } - deriveCorrectAnswerCount(recordData = {}, answers = []) { - const numericCandidates = [ - recordData.correctAnswers, - recordData.correct, - recordData.score, - recordData.scoreInfo?.correct, - recordData.scoreInfo?.score, - recordData.realData?.scoreInfo?.correct, - recordData.realData?.scoreInfo?.score - ]; - for (const candidate of numericCandidates) { - const num = Number(candidate); - if (Number.isFinite(num) && num >= 0) { - return num; - } - } - - if ( - recordData.correctAnswers && - typeof recordData.correctAnswers === 'object' && - !Array.isArray(recordData.correctAnswers) - ) { - let hasBooleanFlag = false; - const correctCount = Object.values(recordData.correctAnswers).reduce((count, value) => { - if (typeof value === 'boolean') { - hasBooleanFlag = true; - return value ? count + 1 : count; - } - if (value && typeof value === 'object') { - const flag = value.isCorrect ?? value.correct; - if (typeof flag === 'boolean') { - hasBooleanFlag = true; - return flag ? count + 1 : count; - } - } - return count; - }, 0); - if (hasBooleanFlag) { - return correctCount; - } - } - - if (Array.isArray(answers) && answers.length > 0) { - const computed = answers.reduce((sum, answer) => { - if (!answer || typeof answer !== 'object') { - return sum; - } - if (answer.correct === true || answer.isCorrect === true) { - return sum + 1; - } - return sum; - }, 0); - if (computed > 0) { - return computed; - } - } - - const detailSources = [ - recordData.answerDetails, - recordData.scoreInfo?.details, - recordData.realData?.scoreInfo?.details - ]; - for (const details of detailSources) { - if (!details || typeof details !== 'object') { - continue; - } - let hasFlag = false; - let correct = 0; - Object.values(details).forEach(detail => { - if (!detail || typeof detail !== 'object') { - return; - } - if (detail.isCorrect === true || detail.correct === true) { - correct += 1; - } - hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean'; - }); - if (hasFlag) { - return correct; - } - } - const answerMap = {}; - if (Array.isArray(answers)) { - answers.forEach((answer) => { - if (!answer || typeof answer !== 'object') { - return; - } - const key = answer.questionId || answer.id || answer.key; - if (key && answer.answer != null) { - answerMap[this.normalizeAnswerMapKey(key)] = answer.answer; - } - }); - } else if (this.isPlainObject(answers)) { - Object.entries(answers).forEach(([key, value]) => { - const normalizedKey = this.normalizeAnswerMapKey(key); - if (normalizedKey && value != null) { - answerMap[normalizedKey] = value; - } - }); - } - const correctMap = this.resolveCorrectAnswerMap(recordData); - if (Object.keys(answerMap).length > 0 && Object.keys(correctMap).length > 0) { - return Object.keys(answerMap).reduce((count, key) => { - if (!Object.prototype.hasOwnProperty.call(correctMap, key)) { - return count; - } - return this.compareAnswerValues(answerMap[key], correctMap[key]) ? count + 1 : count; - }, 0); - } - return 0; - } - - compareAnswerValues(userAnswer, correctAnswer) { - if (userAnswer == null || correctAnswer == null) { - return false; - } - const matchCore = window.AnswerMatchCore; - if (matchCore && typeof matchCore.compareAnswers === 'function') { - return matchCore.compareAnswers(userAnswer, correctAnswer) === true; - } - return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase(); - } - - getDateOnlyIso(value) { - if (!value) return null; - if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { - return value; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return null; - } - const year = parsed.getFullYear(); - const month = String(parsed.getMonth() + 1).padStart(2, '0'); - const day = String(parsed.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - } - - getLocalDayStart(value) { - if (!value) return null; - if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { - const [year, month, day] = value.split('-').map(part => Number(part)); - if ([year, month, day].some(num => Number.isNaN(num))) { - return null; - } - return new Date(year, month - 1, day).getTime(); - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return null; - } - return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()).getTime(); - } - - createStorageAdapter() { - const metaRepo = this.repositories.meta; - const backupRepo = this.repositories.backups; - const keys = this.storageKeys; - const self = this; - - return { - async get(key, defaultValue = null) { - switch (key) { - case keys.practiceRecords: { - const api = self.getPracticeRecordAPI(['list']); - const records = await api.list(); - return Array.isArray(records) ? records : []; - } - case keys.userStats: { - const fallback = defaultValue !== null && defaultValue !== undefined ? defaultValue : self.getDefaultUserStats(); - const api = self.getPracticeRecordAPI(['readStats']); - return await api.readStats({ fallback }); - } - case keys.storageVersion: - return await metaRepo.get('storage_version', defaultValue); - case keys.backupData: - case 'manual_backups': - return await backupRepo.list(); - default: - return await metaRepo.get(key, defaultValue); - } - }, - async set(key, value) { - switch (key) { - case keys.practiceRecords: { - throw new Error('ScoreStorage.storage.set(practice_records) is disabled; use PracticeRecordAPI.replace'); - } - case keys.userStats: { - throw new Error('ScoreStorage.storage.set(user_stats) is disabled; use PracticeRecordAPI.writeStats'); - } - case keys.storageVersion: - await metaRepo.set('storage_version', value); - return true; - case keys.backupData: - case 'manual_backups': - await backupRepo.saveAll(Array.isArray(value) ? value : []); - return true; - default: - await metaRepo.set(key, value); - return true; - } - }, - async remove(key) { - switch (key) { - case keys.practiceRecords: { - throw new Error('ScoreStorage.storage.remove(practice_records) is disabled; use PracticeRecordAPI.clear'); - } - case keys.userStats: { - throw new Error('ScoreStorage.storage.remove(user_stats) is disabled; use PracticeRecordAPI.resetStats'); - } - case keys.storageVersion: - await metaRepo.remove('storage_version'); - return true; - case keys.backupData: - case 'manual_backups': - await backupRepo.clear(); - return true; - default: - await metaRepo.remove(key); - return true; - } - } - }; - } - - /** - * 初始化存储系统 - */ - async initialize() { - try { - console.log('ScoreStorage initialized'); - - // 检查存储版本并迁移数据 - await this.checkStorageVersion(); - - // 初始化数据结构 - await this.initializeDataStructures(); - - // Legacy migration happens at PersistentStore bootstrap, not in runtime services. - - // 暂时禁用清理过期数据,避免误删新记录 - // await this.cleanupExpiredData(); - } catch (error) { - this.initializationError = error; - console.error('[ScoreStorage] 初始化失败', error); - throw error; - } - } - - /** - * 检查存储版本 - */ - async checkStorageVersion() { - const normalizeVersion = v => { - if (v === undefined || v === null) return ''; - const s = String(v).trim(); - return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s; - }; - const storedVersionRaw = await this.storage.get(this.storageKeys.storageVersion); - const storedVersion = normalizeVersion(storedVersionRaw); - const current = normalizeVersion(this.currentVersion); - if (!storedVersion) { - await this.storage.set(this.storageKeys.storageVersion, current); - console.log('Storage version initialized:', current); - return; - } - if (storedVersion !== current) { - await this.migrateData(storedVersion, current); - } else { - console.log('[ScoreStorage] 版本匹配,跳过迁移'); - } - } - - /** - * 数据迁移 - */ - async migrateData(fromVersion, toVersion) { - if (String(fromVersion) === String(toVersion)) { - console.log('[ScoreStorage] migrateData skipped: same version'); - return; - } - console.log(`Migrating data from ${fromVersion} to ${toVersion}`); - - try { - // 备份当前数据 - await this.createBackup('migration_backup', { allowDuringInit: true }); - - // 根据版本执行相应的迁移逻辑 - if (fromVersion < '1.0.0') { - await this.migrateToV1(); - } - - // 更新版本号 - await this.storage.set(this.storageKeys.storageVersion, toVersion); - console.log('Data migration completed successfully'); - - } catch (error) { - console.error('Data migration failed:', error); - // 恢复备份数据 - try { - await this.restoreBackup('migration_backup', { allowDuringInit: true }); - } catch (restoreError) { - console.error('Failed to restore backup:', restoreError); - } - } - } - - /** - * 迁移到版本1.0.0 - */ - async migrateToV1() { - // 标准化练习记录格式 - const records = await this.listPracticeRecordsCanonical(); - const standardizedRecords = records.map(record => this.standardizeRecord(record)); - await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: true }); - } - - /** - * 初始化数据结构 - */ - async initializeDataStructures() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() }); - console.log('[ScoreStorage] 用户统计由 PracticeRecordAPI 管理'); - return; - } - console.warn('[ScoreStorage] PracticeRecordAPI.readStats unavailable, skip stats initialization'); - } - - /** - * 获取默认用户统计 - */ - getDefaultUserStats() { - if (window.ExamData && typeof window.ExamData.createDefaultUserStats === 'function') { - return window.ExamData.createDefaultUserStats(); - } - const now = new Date().toISOString(); - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [], - createdAt: now, - updatedAt: now - }; - } - - /** - * 保存练习记录 - */ - async savePracticeRecord(recordData) { - try { - await this.ensureReady(); - // 标准化记录格式 - const standardizedRecord = this.standardizeRecord(recordData); - - // 验证记录数据 - this.validateRecord(standardizedRecord); - - const practiceRecordApi = window.PracticeRecordAPI; - if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') { - try { - const savedRecord = await practiceRecordApi.saveRecord(standardizedRecord, { - currentVersion: this.currentVersion, - maxRecords: this.maxRecords, - updateStats: true - }); - console.log('Practice record saved:', savedRecord.id); - return savedRecord; - } catch (apiError) { - console.warn('[ScoreStorage] PracticeRecordAPI 保存失败:', apiError); - throw apiError; - } - } - - throw new Error('ScoreStorage.savePracticeRecord: unified store not ready'); - - } catch (error) { - console.error('Failed to save practice record:', error); - throw error; - } - } - - normalizeLegacyRecord(record) { - if (!record || typeof record !== 'object') { - return record; - } - const patched = Object.assign({}, record); - if (Array.isArray(record.suiteEntries)) { - patched.suiteEntries = record.suiteEntries.map(entry => this.clonePlainObject(entry)).filter(Boolean); - } - if (record.suiteMode != null) { - patched.suiteMode = Boolean(record.suiteMode); - } - if (record.suiteSessionId) { - patched.suiteSessionId = record.suiteSessionId; - } - if (record.frequency) { - patched.frequency = record.frequency; - } - const inferredType = this.inferPracticeType(patched); - if (!patched.type) { - patched.type = inferredType; - } - const normalizedMetadata = this.buildMetadata( - Object.assign({}, patched, { metadata: patched.metadata || {} }), - patched.type - ); - patched.metadata = normalizedMetadata; - const normalizedAnswers = this.standardizeAnswers(patched.answers || patched.answerList || []); - patched.answers = normalizedAnswers; - patched.answerList = normalizedAnswers; - const answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - const comparisonSource = patched.answerComparison || patched.realData?.answerComparison || null; - const detailSource = patched.scoreInfo?.details - || patched.realData?.scoreInfo?.details - || patched.answerDetails - || null; - const normalizedCorrectMap = this.resolveCorrectAnswerMap(patched, comparisonSource, detailSource); - patched.correctAnswerMap = normalizedCorrectMap || {}; - if (!patched.answerDetails || typeof patched.answerDetails !== 'object') { - patched.answerDetails = this.buildAnswerDetailsFromMaps(answerMap, patched.correctAnswerMap); - } - const derivedTotals = this.deriveTotalQuestionCount(patched, normalizedAnswers.length); - const derivedCorrect = this.deriveCorrectAnswerCount(patched, normalizedAnswers); - patched.totalQuestions = this.ensureNumber(patched.totalQuestions, derivedTotals); - patched.correctAnswers = this.ensureNumber(patched.correctAnswers, derivedCorrect); - patched.score = this.ensureNumber(patched.score, patched.correctAnswers); - patched.accuracy = this.ensureNumber( - patched.accuracy, - patched.totalQuestions > 0 ? patched.correctAnswers / patched.totalQuestions : 0 - ); - if (!patched.startTime) { - patched.startTime = patched.date || patched.endTime || new Date().toISOString(); - } - if (!patched.endTime) { - patched.endTime = patched.date || patched.startTime; - } - if (!patched.status) { - patched.status = 'completed'; - } - if (!patched.scoreInfo) { - patched.scoreInfo = {}; - } - if (!patched.scoreInfo.details && patched.answerDetails) { - patched.scoreInfo.details = patched.answerDetails; - } - if (patched.realData) { - patched.realData = Object.assign({}, patched.realData, { - answers: patched.realData.answers || answerMap, - correctAnswers: patched.correctAnswerMap, - correctAnswerMap: patched.correctAnswerMap, - scoreInfo: Object.assign({}, patched.realData.scoreInfo || {}, { - details: patched.realData.scoreInfo?.details || patched.answerDetails || null - }) - }); - } - return patched; - } - - needsRecordSanitization(record) { - if (!record || typeof record !== 'object') { - return true; - } - if (!record.type || !record.metadata || !record.metadata.type) { - return true; - } - const numericFields = ['score', 'totalQuestions', 'correctAnswers', 'accuracy', 'duration']; - return numericFields.some((field) => { - if (!Object.prototype.hasOwnProperty.call(record, field)) { - return false; - } - return typeof record[field] !== 'number' || Number.isNaN(record[field]); - }); - } - - /** - * 标准化记录格式 - */ - standardizeRecord(recordData) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.standardizeRecord === 'function') { - return coreContracts.standardizeRecord(recordData, { - currentVersion: this.currentVersion, - generateRecordId: () => this.generateRecordId() - }); - } - const now = new Date().toISOString(); - const type = this.inferPracticeType(recordData); - const recordDate = this.resolveRecordDate(recordData, now); - const resolvedExamId = this.inferExamId(recordData); - const metadata = this.buildMetadata( - Object.assign({}, recordData, { examId: resolvedExamId }), - type - ); - const comparisonSource = recordData.answerComparison - || recordData.realData?.answerComparison - || null; - const normalizedAnswers = this.standardizeAnswers(recordData.answers || recordData.answerList || []); - let answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - // 如果 answers 为空,尝试从 answerComparison 补齐 userAnswer - if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) { - const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer'); - if (Object.keys(fromComparison).length > 0) { - answerMap = fromComparison; - } - } - const suiteSessionId = recordData.suiteSessionId - || recordData.metadata?.suiteSessionId - || null; - if (suiteSessionId && !metadata.suiteSessionId) { - metadata.suiteSessionId = suiteSessionId; - } - const frequency = recordData.frequency || metadata.frequency || null; - if (frequency && !metadata.frequency) { - metadata.frequency = frequency; - } - const normalizedCorrectMap = this.resolveCorrectAnswerMap(recordData, comparisonSource); - const derivedTotalQuestions = this.deriveTotalQuestionCount(recordData, normalizedAnswers.length); - const derivedCorrectAnswers = this.deriveCorrectAnswerCount(recordData, normalizedAnswers); - const totalQuestions = this.ensureNumber(recordData.totalQuestions, derivedTotalQuestions); - const correctAnswers = this.ensureNumber(recordData.correctAnswers, derivedCorrectAnswers); - let accuracy = this.ensureNumber( - recordData.accuracy, - totalQuestions > 0 ? correctAnswers / totalQuestions : 0 - ); - if (accuracy > 1 && accuracy <= 100) { - accuracy = accuracy / 100; // 容错百分比形式 - } - if (!Number.isFinite(accuracy) || accuracy < 0) { - accuracy = 0; - } else if (accuracy > 1) { - accuracy = 1; - } - const detailSource = recordData.answerDetails - || recordData.scoreInfo?.details - || recordData.realData?.scoreInfo?.details - || (comparisonSource ? this.convertComparisonToDetails(comparisonSource) : null) - || this.buildAnswerDetailsFromMaps(answerMap, normalizedCorrectMap); - - const startTime = recordData.startTime && !Number.isNaN(new Date(recordData.startTime).getTime()) - ? new Date(recordData.startTime).toISOString() - : recordDate; - const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime()) - ? new Date(recordData.endTime).toISOString() - : recordDate; - const resolvedTitle = recordData.title - || metadata.examTitle - || metadata.title - || recordData.examTitle - || recordData.examId - || '未命名练习'; - const normalizedSuiteEntries = this.standardizeSuiteEntries(recordData.suiteEntries || []); - const normalizedComparison = comparisonSource && typeof comparisonSource === 'object' - ? this.clonePlainObject(comparisonSource) - : null; - - return { - // 基础信息 - id: recordData.id || this.generateRecordId(), - examId: resolvedExamId, - sessionId: recordData.sessionId, - title: resolvedTitle, - type, - - // 时间信息 - startTime, - endTime, - duration: this.ensureNumber(recordData.duration, 0), - date: recordDate, - - // 成绩信息 - status: recordData.status || 'completed', - score: this.ensureNumber(recordData.score, correctAnswers), - totalQuestions, - correctAnswers, - accuracy, - - // 答题详情 - answers: normalizedAnswers, - answerDetails: detailSource || null, - correctAnswerMap: normalizedCorrectMap || {}, - questionTypePerformance: recordData.questionTypePerformance || {}, - - // 元数据 - metadata, - frequency: frequency || metadata.frequency || null, - suiteMode: Boolean(recordData.suiteMode || (frequency && frequency.toLowerCase() === 'suite')), - suiteSessionId, - suiteEntries: normalizedSuiteEntries, - scoreInfo: recordData.scoreInfo - ? Object.assign({}, recordData.scoreInfo, { - details: recordData.scoreInfo.details || detailSource || null - }) - : (detailSource ? { details: detailSource } : null), - realData: recordData.realData - ? Object.assign({}, recordData.realData, { - answers: recordData.realData.answers || answerMap, - correctAnswers: normalizedCorrectMap, - correctAnswerMap: normalizedCorrectMap, - scoreInfo: Object.assign({}, recordData.realData.scoreInfo || {}, { - details: recordData.realData.scoreInfo?.details || detailSource || null - }), - answerComparison: recordData.realData.answerComparison - ? this.clonePlainObject(recordData.realData.answerComparison) - : (normalizedComparison || null) - }) - : (normalizedComparison ? { answerComparison: normalizedComparison } : null), - answerComparison: normalizedComparison, - - // 系统信息 - version: this.currentVersion, - createdAt: recordData.createdAt || now, - updatedAt: now - }; - } - - /** - * 标准化答案格式 - */ - standardizeAnswers(answers) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.buildAnswerArray === 'function') { - return coreContracts.buildAnswerArray(answers); - } - if (!Array.isArray(answers)) { - if (answers && typeof answers === 'object') { - answers = Object.entries(answers).map(([questionId, value]) => ({ - questionId, - answer: value - })); - } else { - answers = []; - } - } - return answers.map((answer, index) => ({ - questionId: answer.questionId || `q${index + 1}`, - answer: answer.answer || '', - correctAnswer: answer.correctAnswer || '', - correct: Boolean(answer.correct), - timeSpent: answer.timeSpent || 0, - questionType: answer.questionType || 'unknown', - timestamp: answer.timestamp || new Date().toISOString() - })); - } - - clonePlainObject(value) { - if (value == null || typeof value !== 'object') { - return value ?? null; - } - if (Array.isArray(value)) { - return value.map(item => this.clonePlainObject(item)).filter(item => item !== undefined); - } - const clone = {}; - Object.keys(value).forEach((key) => { - const entry = value[key]; - clone[key] = (entry && typeof entry === 'object') - ? this.clonePlainObject(entry) - : entry; - }); - return clone; - } - - isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); - } - - normalizeAnswerMapKey(key) { - if (key == null) { - return ''; - } - let normalizedKey = String(key).trim(); - if (!normalizedKey) { - return ''; - } - if (/^\d+$/.test(normalizedKey)) { - normalizedKey = `q${normalizedKey}`; - } else if (normalizedKey.startsWith('question')) { - normalizedKey = normalizedKey.replace('question', 'q'); - } - return normalizedKey; - } - - mergeAnswerMaps(...sources) { - const merged = {}; - sources.forEach((source) => { - if (!this.isPlainObject(source)) { - return; - } - Object.entries(source).forEach(([key, value]) => { - const normalizedKey = this.normalizeAnswerMapKey(key); - if (!normalizedKey || Object.prototype.hasOwnProperty.call(merged, normalizedKey)) { - return; - } - if (value == null || String(value).trim() === '') { - return; - } - merged[normalizedKey] = value; - }); - }); - return merged; - } - - convertComparisonToMap(comparison, key = 'correctAnswer') { - if (!comparison || typeof comparison !== 'object') { - return {}; - } - const map = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (!entry || typeof entry !== 'object') return; - const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.user); - if (value != null && String(value).trim() !== '') { - map[questionId] = value; - } - }); - return map; - } - - resolveCorrectAnswerMap(recordData = {}, comparisonSource = null, detailSource = null) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.resolveRecordCorrectAnswerMap === 'function') { - return coreContracts.resolveRecordCorrectAnswerMap(recordData, { - comparison: comparisonSource, - detailSources: detailSource ? [detailSource] : [] - }); - } - const realData = this.isPlainObject(recordData.realData) ? recordData.realData : {}; - const effectiveComparison = comparisonSource || recordData.answerComparison || realData.answerComparison || null; - return this.mergeAnswerMaps( - recordData.correctAnswerMap, - realData.correctAnswerMap, - recordData.correctAnswers, - realData.correctAnswers, - effectiveComparison ? this.convertComparisonToMap(effectiveComparison, 'correctAnswer') : null, - recordData.answerDetails ? this.deriveCorrectMapFromDetails(recordData.answerDetails) : null, - detailSource ? this.deriveCorrectMapFromDetails(detailSource) : null, - recordData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(recordData.scoreInfo.details) : null, - realData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(realData.scoreInfo.details) : null - ); - } - - convertComparisonToDetails(comparison) { - if (!comparison || typeof comparison !== 'object') { - return null; - } - const details = {}; - Object.entries(comparison).forEach(([questionId, entry]) => { - if (!entry || typeof entry !== 'object') return; - details[questionId] = { - userAnswer: entry.userAnswer ?? entry.user ?? '', - correctAnswer: entry.correctAnswer ?? entry.correct ?? '', - isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null - }; - }); - return details; - } - - standardizeSuiteEntries(entries) { - if (!Array.isArray(entries)) { - return []; - } - return entries.map((entry, index) => { - if (!entry || typeof entry !== 'object') { - return null; - } - const normalizedAnswers = this.standardizeAnswers(entry.answers || entry.answerList || []); - const answerMap = normalizedAnswers.reduce((map, item) => { - if (item && item.questionId) { - map[item.questionId] = item.answer || ''; - } - return map; - }, {}); - const normalizedScoreInfo = entry.scoreInfo - ? Object.assign({}, entry.scoreInfo, { - details: entry.scoreInfo?.details - ? this.clonePlainObject(entry.scoreInfo.details) - : null - }) - : null; - const answerComparisonSource = entry.answerComparison - || normalizedScoreInfo?.details - || entry.rawData?.answerComparison - || null; - const normalizedCorrectMap = this.resolveCorrectAnswerMap( - entry, - answerComparisonSource, - normalizedScoreInfo?.details || entry.rawData?.scoreInfo?.details || null - ); - const highlights = Array.isArray(entry.highlights) - ? entry.highlights.slice() - : (Array.isArray(entry.rawData?.highlights) ? entry.rawData.highlights.slice() : []); - const scrollY = Number.isFinite(Number(entry.scrollY)) - ? Number(entry.scrollY) - : (Number.isFinite(Number(entry.rawData?.scrollY)) ? Number(entry.rawData.scrollY) : 0); - return { - examId: entry.examId || null, - title: entry.title || entry.examTitle || `套题第${index + 1}篇`, - category: entry.category || entry.metadata?.category || '套题', - duration: this.ensureNumber(entry.duration, 0), - scoreInfo: normalizedScoreInfo, - answers: answerMap, - correctAnswerMap: normalizedCorrectMap, - answerComparison: this.clonePlainObject(answerComparisonSource) || null, - metadata: entry.metadata ? Object.assign({}, entry.metadata) : {}, - highlights, - scrollY, - rawData: entry.rawData ? this.clonePlainObject(entry.rawData) : null - }; - }).filter(Boolean); - } - - deriveCorrectMapFromDetails(details) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.deriveCorrectMapFromDetails === 'function') { - return coreContracts.deriveCorrectMapFromDetails(details); - } - if (!details || typeof details !== 'object') { - return {}; - } - const map = {}; - Object.entries(details).forEach(([questionId, info]) => { - if (!info) { - return; - } - const correctAnswer = info.correctAnswer || info.answer || info.value; - if (correctAnswer != null) { - map[questionId] = (typeof correctAnswer === 'string') - ? correctAnswer.trim() - : String(correctAnswer); - } - }); - return map; - } - - buildAnswerDetailsFromMaps(answerMap = {}, correctMap = {}) { - const coreContracts = window.PracticeCore && window.PracticeCore.contracts; - if (coreContracts && typeof coreContracts.buildAnswerDetails === 'function') { - return coreContracts.buildAnswerDetails(answerMap, correctMap); - } - const details = {}; - const keys = new Set([ - ...Object.keys(answerMap || {}), - ...Object.keys(correctMap || {}) - ]); - keys.forEach((questionId) => { - const userAnswer = answerMap && answerMap[questionId] ? String(answerMap[questionId]) : '-'; - const correctAnswer = correctMap && correctMap[questionId] ? String(correctMap[questionId]) : '-'; - let isCorrect = null; - if (correctAnswer !== '-') { - const matchCore = window.AnswerMatchCore; - isCorrect = matchCore && typeof matchCore.compareAnswers === 'function' - ? matchCore.compareAnswers(userAnswer, correctAnswer) === true - : userAnswer.toLowerCase() === correctAnswer.toLowerCase(); - } - details[questionId] = { - userAnswer, - correctAnswer, - isCorrect - }; - }); - return details; - } - - /** - * 验证记录数据 - */ - validateRecord(record) { - const requiredFields = ['id', 'examId', 'startTime', 'endTime']; - - for (const field of requiredFields) { - if (!record[field]) { - throw new Error(`Missing required field: ${field}`); - } - } - - // 验证时间格式 - if (new Date(record.startTime).toString() === 'Invalid Date') { - throw new Error('Invalid startTime format'); - } - - if (new Date(record.endTime).toString() === 'Invalid Date') { - throw new Error('Invalid endTime format'); - } - - // 验证数值范围 - record.accuracy = Math.max(0, Math.min(1, Number(record.accuracy) || 0)); - - record.duration = Number.isFinite(record.duration) && record.duration >= 0 - ? record.duration - : 0; - } - - /** - * 更新用户统计 - */ - async updateUserStats(practiceRecord, options = {}) { - const { allowDuringInit = false } = options; - await this.recalculateUserStats({ allowDuringInit }); - } - - applyRecordToStats(stats, practiceRecord) { - if (!stats || typeof stats !== 'object') { - return; - } - - const duration = Number(practiceRecord.duration) || 0; - const accuracy = Number(practiceRecord.accuracy) || 0; - const normalizedRecord = { ...practiceRecord, duration, accuracy }; - - stats.categoryStats = stats.categoryStats && typeof stats.categoryStats === 'object' ? stats.categoryStats : {}; - stats.questionTypeStats = stats.questionTypeStats && typeof stats.questionTypeStats === 'object' ? stats.questionTypeStats : {}; - - stats.totalPractices += 1; - stats.totalTimeSpent += duration; - - const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy; - stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0; - - this.updateCategoryStats(stats, normalizedRecord); - this.updateQuestionTypeStats(stats, normalizedRecord); - this.updateStreakDays(stats, normalizedRecord); - this.checkAchievements(stats, normalizedRecord); - - stats.updatedAt = new Date().toISOString(); - } - - /** - * 更新分类统计 - */ - updateCategoryStats(stats, practiceRecord) { - const category = practiceRecord?.metadata?.category; - if (!category) return; - - if (!stats.categoryStats[category]) { - stats.categoryStats[category] = { - practices: 0, - avgScore: 0, - timeSpent: 0, - bestScore: 0, - totalQuestions: 0, - correctAnswers: 0 - }; - } - - const catStats = stats.categoryStats[category]; - catStats.practices += 1; - catStats.timeSpent += practiceRecord.duration; - catStats.totalQuestions += practiceRecord.totalQuestions; - catStats.correctAnswers += practiceRecord.correctAnswers; - catStats.bestScore = Math.max(catStats.bestScore, practiceRecord.accuracy); - - // 重新计算平均分数 - const catTotalScore = (catStats.avgScore * (catStats.practices - 1)) + practiceRecord.accuracy; - catStats.avgScore = catTotalScore / catStats.practices; - } - - /** - * 更新题型统计 - */ - updateQuestionTypeStats(stats, practiceRecord) { - if (!practiceRecord.questionTypePerformance) return; - - Object.entries(practiceRecord.questionTypePerformance).forEach(([type, performance]) => { - if (!stats.questionTypeStats[type]) { - stats.questionTypeStats[type] = { - practices: 0, - accuracy: 0, - totalQuestions: 0, - correctAnswers: 0, - avgTimePerQuestion: 0 - }; - } - - const typeStats = stats.questionTypeStats[type]; - typeStats.practices += 1; - typeStats.totalQuestions += performance.total || 0; - typeStats.correctAnswers += performance.correct || 0; - - // 重新计算准确率 - typeStats.accuracy = typeStats.totalQuestions > 0 - ? typeStats.correctAnswers / typeStats.totalQuestions - : 0; - - // 计算平均每题用时 - if (performance.timeSpent && performance.total) { - const newAvgTime = performance.timeSpent / performance.total; - typeStats.avgTimePerQuestion = (typeStats.avgTimePerQuestion * (typeStats.practices - 1) + newAvgTime) / typeStats.practices; - } - }); - } - - /** - * 更新连续学习天数 - */ - updateStreakDays(stats, practiceRecord) { - const recordSource = practiceRecord.date || practiceRecord.endTime || practiceRecord.startTime; - const recordDay = this.getDateOnlyIso(recordSource); - if (!recordDay) return; - - const dayMs = 24 * 60 * 60 * 1000; - let practiceDays = Array.isArray(stats.practiceDays) ? stats.practiceDays.slice() : []; - - if (practiceDays.length === 0) { - const historicalStreak = Math.max(0, Math.round(this.ensureNumber(stats.streakDays, 0))); - const lastPracticeIso = this.getDateOnlyIso(stats.lastPracticeDate); - const lastPracticeStart = this.getLocalDayStart(lastPracticeIso); - - if (historicalStreak > 0 && lastPracticeIso && Number.isFinite(lastPracticeStart)) { - const migratedDays = []; - for (let offset = historicalStreak - 1; offset >= 0; offset -= 1) { - const timestamp = lastPracticeStart - (offset * dayMs); - const dayIso = this.getDateOnlyIso(timestamp); - if (dayIso) { - migratedDays.push(dayIso); - } - } - practiceDays = migratedDays; - } - } - - const uniqueDays = new Set(practiceDays); - uniqueDays.add(recordDay); - practiceDays = Array.from(uniqueDays); - - const validDays = practiceDays - .map(day => ({ day, start: this.getLocalDayStart(day) })) - .filter(item => item.start !== null) - .sort((a, b) => a.start - b.start); - - if (validDays.length === 0) { - stats.practiceDays = []; - stats.streakDays = 0; - stats.lastPracticeDate = null; - return; - } - - let currentStreak = 1; - - for (let index = 1; index < validDays.length; index += 1) { - const previous = validDays[index - 1]; - const current = validDays[index]; - const diff = Math.round((current.start - previous.start) / (1000 * 60 * 60 * 24)); - - if (diff === 1) { - currentStreak += 1; - } else if (diff > 1) { - currentStreak = 1; - } - } - - stats.practiceDays = validDays.map(item => item.day); - stats.streakDays = currentStreak; - stats.lastPracticeDate = validDays[validDays.length - 1].day; - } - - /** - * 检查成就 - */ - checkAchievements(stats, practiceRecord) { - const achievements = stats.achievements || []; - - // 首次练习成就 - if (stats.totalPractices === 1 && !achievements.includes('first-practice')) { - achievements.push('first-practice'); - } - - // 连续学习成就 - if (stats.streakDays >= 7 && !achievements.includes('week-streak')) { - achievements.push('week-streak'); - } - - if (stats.streakDays >= 30 && !achievements.includes('month-streak')) { - achievements.push('month-streak'); - } - - // 高分成就 - if (practiceRecord.accuracy >= 0.9 && !achievements.includes('high-scorer')) { - achievements.push('high-scorer'); - } - - // 分类掌握成就 - const category = practiceRecord.metadata.category; - if (category && stats.categoryStats[category]) { - const catStats = stats.categoryStats[category]; - if (catStats.practices >= 10 && catStats.avgScore >= 0.8) { - const achievementKey = `${category.toLowerCase()}-master`; - if (!achievements.includes(achievementKey)) { - achievements.push(achievementKey); - } - } - } - - stats.achievements = achievements; - } - - /** - * 获取练习记录 - */ - async getPracticeRecords(filters = {}) { - await this.ensureReady(); - const raw = await this.listPracticeRecordsCanonical(); - const base = Array.isArray(raw) ? raw : []; - // Normalize each record to ensure UI can rely on a stable shape - const records = base.map(r => this.normalizeRecordFields(r)); - - if (Object.keys(filters).length === 0) { - return records.sort((a, b) => new Date(b.startTime) - new Date(a.startTime)); - } - - return records.filter(record => { - // 按考试ID筛选 - if (filters.examId && record.examId !== filters.examId) return false; - - // 按分类筛选 - if (filters.category && record.metadata.category !== filters.category) return false; - - // 按时间范围筛选 - if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false; - if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false; - - // 按准确率筛选 - if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false; - if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false; - - // 按状态筛选 - if (filters.status && record.status !== filters.status) return false; - - return true; - }).sort((a, b) => new Date(b.startTime) - new Date(a.startTime)); - } - - /** - * 获取用户统计 - */ - async getUserStats(options = {}) { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - const api = this.getPracticeRecordAPI(['readStats']); - return await api.readStats({ fallback: this.getDefaultUserStats() }); - } - - /** - * 重新计算用户统计 - */ - async recalculateUserStats(options = {}) { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - const api = this.getPracticeRecordAPI(['recalculateStats']); - const stats = await api.recalculateStats(); - console.log('User stats recalculated through PracticeRecordAPI'); - return stats; - } - - /** - * 将不同来源/版本的记录统一为稳定字段,以便 UI/统计可靠工作 - * 不修改存储中的原始对象,仅在返回路径做兼容填充 - */ - normalizeRecordFields(record) { - try { - const r = { ...(record || {}) }; - - // metadata 兜底 - r.metadata = { - examTitle: (r.metadata && r.metadata.examTitle) || r.title || r.examTitle || r.examId || '', - category: (r.metadata && r.metadata.category) || r.category || '', - frequency: (r.metadata && r.metadata.frequency) || r.frequency || '', - ...(r.metadata || {}) - }; - - // 时间字段归一 - const rd = r.realData || {}; - if (!r.startTime) { - if (typeof rd.startTime === 'number') { - r.startTime = new Date(rd.startTime).toISOString(); - } else if (rd.startTime) { - r.startTime = new Date(rd.startTime).toISOString(); - } else if (r.date) { - r.startTime = new Date(r.date).toISOString(); - } - } - if (!r.endTime) { - if (typeof rd.endTime === 'number') { - r.endTime = new Date(rd.endTime).toISOString(); - } else if (rd.endTime) { - r.endTime = new Date(rd.endTime).toISOString(); - } else if (r.startTime && (r.duration || rd.duration)) { - const base = new Date(r.startTime).getTime(); - const seconds = (Number(r.duration || rd.duration) || 0); - r.endTime = new Date(base + seconds * 1000).toISOString(); - } - } - - // 用时归一(秒): consider multiple possible fields; prefer positive seconds - if (!(typeof r.duration === 'number' && isFinite(r.duration) && r.duration > 0)) { - const sInfo = r.scoreInfo || rd.scoreInfo || {}; - const candidates = [ - r.duration, rd.duration, r.durationSeconds, r.duration_seconds, - r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent, - rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent, - sInfo.duration, sInfo.timeSpent - ]; - let picked; - for (const v of candidates) { - const n = Number(v); - if (Number.isFinite(n) && n > 0) { picked = n; break; } - } - if (picked !== undefined) { - r.duration = Math.floor(picked); - } else if (r.startTime && r.endTime) { - r.duration = Math.max(0, Math.floor((new Date(r.endTime) - new Date(r.startTime)) / 1000)); - } else if (Array.isArray(rd.interactions) && rd.interactions.length) { - // Derive from interactions timestamp span - try { - const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n)); - if (ts.length) { - const span = Math.max(...ts) - Math.min(...ts); - if (Number.isFinite(span) && span > 0) r.duration = Math.floor(span / 1000); - } - } catch(_) {} - } else { - r.duration = 0; - } - } - - // scoreInfo 归一 - const sInfo = r.scoreInfo || rd.scoreInfo || {}; - if (!r.scoreInfo && (rd.scoreInfo || r.answerComparison)) { - r.scoreInfo = sInfo; - } - - // answers 归一 - if (!r.answers && rd.answers) { - r.answers = rd.answers; - } - if (Array.isArray(r.answers)) { - const map = {}; - r.answers.forEach((entry, idx) => { - if (!entry) return; - const key = entry.questionId || `q${idx + 1}`; - map[key] = entry.answer || entry.userAnswer || ''; - }); - r.answerList = r.answers.slice(); - r.answers = map; - } - if (Array.isArray(rd.answers)) { - const rdMap = {}; - rd.answers.forEach((entry, idx) => { - if (!entry) return; - const key = entry.questionId || `q${idx + 1}`; - rdMap[key] = entry.answer || entry.userAnswer || ''; - }); - rd.answers = rdMap; - } - const comparisonSource = r.answerComparison || rd.answerComparison || null; - if ((!r.answers || Object.keys(r.answers).length === 0) && comparisonSource) { - const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer'); - if (Object.keys(fromComparison).length > 0) { - r.answers = fromComparison; - } - } - const normalizedCorrectMap = this.resolveCorrectAnswerMap( - r, - comparisonSource, - r.answerDetails || r.scoreInfo?.details || rd.scoreInfo?.details || null - ); - if (Object.keys(normalizedCorrectMap).length > 0) { - r.correctAnswerMap = normalizedCorrectMap; - } - if (!r.answerDetails) { - if (comparisonSource) { - r.answerDetails = this.convertComparisonToDetails(comparisonSource); - } - if (!r.answerDetails) { - r.answerDetails = r.scoreInfo?.details || this.buildAnswerDetailsFromMaps(r.answers, r.correctAnswerMap); - } - } - - // 正确/总题数归一 - const derivedCorrect = (typeof r.correctAnswers === 'number') ? r.correctAnswers - : (typeof r.score === 'number' ? r.score - : (typeof sInfo.correct === 'number' - ? sInfo.correct - : this.deriveCorrectAnswerCount(r, r.answers || []))); - - const derivedTotal = (typeof r.totalQuestions === 'number') ? r.totalQuestions - : (typeof sInfo.total === 'number' ? sInfo.total - : (r.realData && typeof r.realData.totalQuestions === 'number' ? r.realData.totalQuestions - : (r.answers ? Object.keys(r.answers).length - : (rd.answers ? Object.keys(rd.answers || {}).length : null)))); - - if (typeof r.correctAnswers !== 'number' && derivedCorrect != null) { - r.correctAnswers = derivedCorrect; - } - if (typeof r.totalQuestions !== 'number' && derivedTotal != null) { - r.totalQuestions = derivedTotal; - } - if (r.realData && typeof r.realData === 'object') { - r.realData.correctAnswers = r.correctAnswerMap || {}; - r.realData.correctAnswerMap = r.correctAnswerMap || {}; - } - - // 准确率/百分比归一 - let acc = (typeof r.accuracy === 'number') ? r.accuracy - : (typeof sInfo.accuracy === 'number' ? sInfo.accuracy : null); - if (acc == null) { - if (typeof r.correctAnswers === 'number' && typeof r.totalQuestions === 'number' && r.totalQuestions > 0) { - acc = r.correctAnswers / r.totalQuestions; - } else { - acc = 0; - } - } - r.accuracy = acc; - - if (typeof r.percentage !== 'number' || isNaN(r.percentage)) { - if (typeof sInfo.percentage === 'number') { - r.percentage = sInfo.percentage; - } else { - r.percentage = Math.round(acc * 100); - } - } - - // 状态兜底 - if (!r.status) r.status = 'completed'; - - return r; - } catch (e) { - try { console.warn('[ScoreStorage] normalizeRecordFields failed:', e); } catch(_) {} - return record; - } - } - - /** - * 创建数据备份 - 统一走 BackupAPI → BackupRepository - */ - async createBackup(backupName = null, options = {}) { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - - if (window.BackupAPI && typeof window.BackupAPI.create === 'function') { - const practiceRecords = await this.listPracticeRecordsCanonical(); - const userStats = await this.getUserStats({ allowDuringInit }); - const storageVersion = await this.storage.get(this.storageKeys.storageVersion); - const examIndex = await this.storage.get('exam_index', []); - const backupId = await window.BackupAPI.create({ - id: backupName || undefined, - type: 'score_storage', - data: { - practice_records: practiceRecords, - user_stats: userStats, - exam_index: Array.isArray(examIndex) ? examIndex : [], - storage_version: storageVersion - } - }); - console.log('[ScoreStorage] Backup created via BackupAPI:', backupId); - return backupId; - } - - // Fallback: DataBackupManager path (still ends at BackupAPI if loaded) - if (window.DataBackupManager) { - const backupManager = new DataBackupManager(); - const backupId = await backupManager.createBackup( - backupName || `score_backup_${Date.now()}`, - 'score_storage' - ); - console.log('[ScoreStorage] Backup created via DataBackupManager:', backupId); - return backupId; - } - - console.warn('[ScoreStorage] BackupAPI not available, skipping backup'); - return null; - } - - /** - * 恢复数据备份 - 统一走 BackupAPI - */ - async restoreBackup(backupId, options = {}) { - try { - const { allowDuringInit = false } = options; - await this.ensureReady({ allowDuringInit }); - - if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') { - const result = await window.BackupAPI.restore(backupId); - console.log('[ScoreStorage] Backup restored via BackupAPI:', backupId); - return result.backup; - } - - // Fallback dual-schema restore when BackupAPI missing - const backups = await this.storage.get('manual_backups', []); - const backup = backups.find(b => b.id === backupId); - - if (!backup) { - throw new Error(`Backup not found: ${backupId}`); - } - - if (backup.data) { - const data = backup.data; - const records = Array.isArray(data.practiceRecords) - ? data.practiceRecords - : (Array.isArray(data.practice_records) ? data.practice_records : []); - const stats = (data.userStats && typeof data.userStats === 'object') - ? data.userStats - : ((data.user_stats && typeof data.user_stats === 'object') ? data.user_stats : null); - const hasStats = Boolean(stats); - await this.replacePracticeRecordsCanonical(records, { updateStats: !hasStats }); - if (hasStats) { - const api = this.getPracticeRecordAPI(['resetStats']); - await api.resetStats(stats); - } - if (data.storageVersion || data.storage_version) { - await this.storage.set(this.storageKeys.storageVersion, data.storageVersion || data.storage_version); - } - const examIndex = Array.isArray(data.exam_index) - ? data.exam_index - : (Array.isArray(data.examIndex) ? data.examIndex : null); - if (examIndex) { - await this.storage.set('exam_index', examIndex); - } - } - - console.log('[ScoreStorage] Backup restored:', backupId); - return backup; - } catch (error) { - console.error('[ScoreStorage] Failed to restore backup:', error); - throw error; - } - } - - /** - * 获取备份列表 - 统一走 BackupAPI - */ - async getBackups() { - try { - await this.ensureReady(); - if (window.BackupAPI && typeof window.BackupAPI.list === 'function') { - const backups = await window.BackupAPI.list(); - return (Array.isArray(backups) ? backups : []) - .slice() - .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } - const backups = await this.storage.get('manual_backups', []); - return (Array.isArray(backups) ? backups : []) - .slice() - .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } catch (error) { - console.error('[ScoreStorage] Failed to get backups:', error); - return []; - } - } - - /** - * 导出数据 - */ - async exportData(format = 'json') { - await this.ensureReady(); - const exportData = { - exportDate: new Date().toISOString(), - version: this.currentVersion, - practiceRecords: await this.listPracticeRecordsCanonical(), - userStats: await this.getUserStats(), - backups: await this.storage.get(this.storageKeys.backupData, []) - }; - - switch (format.toLowerCase()) { - case 'json': - return JSON.stringify(exportData, null, 2); - case 'csv': - return this.convertToCSV(exportData.practiceRecords); - default: - throw new Error(`Unsupported export format: ${format}`); - } - } - - /** - * 转换为CSV格式 - */ - convertToCSV(records) { - if (records.length === 0) return ''; - - const headers = [ - 'ID', '考试ID', '开始时间', '结束时间', '用时(秒)', - '状态', '分数', '总题数', '正确数', '准确率', - '分类', '频率', '题目标题' - ]; - - const rows = records.map(record => [ - record.id, - record.examId, - record.startTime, - record.endTime, - record.duration, - record.status, - record.score, - record.totalQuestions, - record.correctAnswers, - Math.round(record.accuracy * 100) + '%', - record.metadata.category || '', - record.metadata.frequency || '', - record.metadata.examTitle || '' - ]); - - return [headers, ...rows] - .map(row => row.map(cell => `"${cell}"`).join(',')) - .join('\n'); - } - - /** - * 导入数据 - */ - async importData(importData, options = {}) { - try { - await this.ensureReady(); - const payload = typeof importData === 'string' ? JSON.parse(importData) : importData; - - const records = this.extractPracticeRecordsFromPayload(payload); - const stats = this.extractUserStatsFromPayload(payload); - - if (!Array.isArray(records) || records.length === 0) { - throw new Error('Invalid import data format: no practice records found'); - } - - // 标准化记录,避免字段缺失 - const standardizedRecords = records.map((r) => { - try { - return this.standardizeRecord(r); - } catch (e) { - console.warn('[ScoreStorage] 标准化导入记录失败,跳过:', r && r.id, e); - return null; - } - }).filter(Boolean); - - // 创建备份 - await this.createBackup('pre_import_backup'); - - if (options.merge) { - // 合并模式:按 id 去重,保留导入集中的最新(后出现的覆盖) - const existingRecords = await this.listPracticeRecordsCanonical(); - const mergedMap = new Map(); - existingRecords.forEach((rec) => { - if (rec && rec.id) mergedMap.set(rec.id, rec); - }); - standardizedRecords.forEach((rec) => { - if (rec && rec.id) mergedMap.set(rec.id, rec); - }); - const mergedRecords = Array.from(mergedMap.values()); - await this.replacePracticeRecordsCanonical(mergedRecords, { updateStats: true }); - console.log(`Imported ${standardizedRecords.length} records (merge mode), total ${mergedRecords.length}`); - - } else { - // 替换模式:完全替换数据 - await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: !stats }); - - if (stats) { - const api = this.getPracticeRecordAPI(['writeStats']); - await api.writeStats(stats); - } - - console.log(`Imported ${standardizedRecords.length} records (replace mode)`); - } - - return true; - - } catch (error) { - console.error('Failed to import data:', error); - throw error; - } - } - - extractPracticeRecordsFromPayload(payload) { - if (!payload) return []; - if (Array.isArray(payload)) return payload; - if (Array.isArray(payload.practiceRecords)) return payload.practiceRecords; - if (Array.isArray(payload.practice_records)) return payload.practice_records; - if (Array.isArray(payload.data?.practice_records)) return payload.data.practice_records; - if (Array.isArray(payload.data?.practiceRecords)) return payload.data.practiceRecords; - if (payload.data?.exam_system_practice_records && Array.isArray(payload.data.exam_system_practice_records.data)) { - return payload.data.exam_system_practice_records.data; - } - if (payload.exam_system_practice_records && Array.isArray(payload.exam_system_practice_records.data)) { - return payload.exam_system_practice_records.data; - } - return []; - } - - extractUserStatsFromPayload(payload) { - if (!payload || typeof payload !== 'object') return null; - return payload.userStats - || payload.user_stats - || payload.data?.userStats - || payload.data?.user_stats - || null; - } - - // Note: 备份相关方法已移除,现在使用DataBackupManager - - /** - * 生成记录ID - */ - generateRecordId() { - return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - /** - * 获取存储统计信息 - */ - async getStorageStats() { - await this.ensureReady(); - const records = await this.listPracticeRecordsCanonical(); - const backups = await this.storage.get(this.storageKeys.backupData, []); - - return { - totalRecords: records.length, - totalBackups: backups.length, - oldestRecord: records.length > 0 ? records[0].startTime : null, - newestRecord: records.length > 0 ? records[records.length - 1].startTime : null, - storageVersion: await this.storage.get(this.storageKeys.storageVersion), - estimatedSize: await this.estimateStorageSize() - }; - } - - /** - * 估算存储大小 - */ - async estimateStorageSize() { - await this.ensureReady(); - const data = { - practiceRecords: await this.listPracticeRecordsCanonical(), - userStats: await this.getUserStats(), - backupData: await this.storage.get(this.storageKeys.backupData, []) - }; - - const jsonString = JSON.stringify(data); - return jsonString.length; // 字节数的近似值 - } - - // Note: destroy方法已移除,因为备份功能现在由DataBackupManager处理 -} - -// 确保全局可用 -window.ScoreStorage = ScoreStorage; - - -/* ===== js/utils/answerSanitizer.js ===== */ -(function (global) { - 'use strict'; - - function toStringSafe(value) { - if (value === null || value === undefined) { - return ''; - } - return String(value); - } - - function normalizeFromObject(object) { - if (!object || typeof object !== 'object') { - return ''; - } - const preferKeys = [ - 'value', - 'answerValue', - 'key', - 'option', - 'heading', - 'word', - 'label', - 'answerLabel', - 'text', - 'answer', - 'content' - ]; - for (var i = 0; i < preferKeys.length; i += 1) { - var key = preferKeys[i]; - if (typeof object[key] === 'string' && object[key].trim()) { - return object[key].trim(); - } - } - if (typeof object.innerText === 'string' && object.innerText.trim()) { - return object.innerText.trim(); - } - if (typeof object.textContent === 'string' && object.textContent.trim()) { - return object.textContent.trim(); - } - try { - var serialized = JSON.stringify(object); - if (serialized && serialized !== '{}' && serialized !== '[]') { - return serialized; - } - } catch (_) {} - return toStringSafe(object); - } - - function normalizeValue(value) { - if (value === null || value === undefined) { - return ''; - } - if (typeof value === 'string') { - var trimmed = value.trim(); - if (/^\[object\s/i.test(trimmed)) { - return ''; - } - return trimmed; - } - if (typeof value === 'boolean') { - return value ? 'True' : 'False'; - } - if (typeof value === 'number') { - return toStringSafe(value).trim(); - } - if (Array.isArray(value)) { - var normalizedArray = value - .map(function (item) { return normalizeValue(item); }) - .filter(function (item) { return item !== null && item !== undefined && item !== ''; }) - .join(', '); - return normalizedArray.trim(); - } - return normalizeFromObject(value).replace(/^\[object\s[^\]]+\]$/i, '').trim(); - } - - function hasMeaningfulValue(value) { - var normalized = normalizeValue(value); - if (!normalized) { - return false; - } - var lowered = normalized.toLowerCase(); - if (lowered === 'n/a' || lowered === 'no answer' || lowered === '未作答' || lowered === '无' || lowered === 'none') { - return false; - } - return true; - } - - function normalizeValueList(value) { - var values = Array.isArray(value) ? value : (value === null || value === undefined ? [] : [value]); - var normalized = []; - values.forEach(function (item) { - var text = normalizeValue(item); - if (!hasMeaningfulValue(text)) { - return; - } - if (!normalized.some(function (existing) { return existing.toLowerCase() === text.toLowerCase(); })) { - normalized.push(text); - } - }); - return normalized; - } - - function sanitizeComparisonMap(comparisonMap) { - if (!comparisonMap || typeof comparisonMap !== 'object') { - return {}; + function sanitizeComparisonMap(comparisonMap) { + if (!comparisonMap || typeof comparisonMap !== 'object') { + return {}; } var sanitized = {}; Object.keys(comparisonMap).forEach(function (key) { @@ -5855,8 +3912,6 @@ window.ScoreStorage = ScoreStorage; /* ===== js/core/practiceRecorder.js ===== */ -const PRACTICE_RECORDER_EXPORT_VERSION = '0.6.2-fix'; - /** * 练习记录管理器 * 负责练习会话管理、成绩记录和数据持久化 @@ -5868,19 +3923,11 @@ class PracticeRecorder { this.autoSaveInterval = 30000; // 30秒自动保存 this.autoSaveTimer = null; - // 初始化存储系统 - this.scoreStorage = new ScoreStorage(); - this.repositories = window.dataRepositories; - if (!this.repositories) { - throw new Error('数据仓库未初始化,PracticeRecorder 无法构建'); - } - this.metaRepo = this.repositories.meta; - this.practiceTypeCache = new Map(); // 异步初始化 this.ready = (async () => { - await this.scoreStorage.ready; + await window.AppData.ready; await this.initialize(); })(); @@ -5915,6 +3962,72 @@ class PracticeRecorder { throw new Error(`PracticeRecorder requires PracticeCore.contracts.${name}`); } + clonePlainObject(value) { + const coreContracts = this.getCoreContracts(); + if (coreContracts && typeof coreContracts.clonePlainObject === 'function') { + return coreContracts.clonePlainObject(value); + } + if (value == null || typeof value !== 'object') { + return value ?? null; + } + if (Array.isArray(value)) { + return value.map((item) => this.clonePlainObject(item)); + } + const clone = {}; + Object.keys(value).forEach((key) => { + clone[key] = this.clonePlainObject(value[key]); + }); + return clone; + } + + activeSessionEntityId(sessionOrId) { + const rawId = sessionOrId && typeof sessionOrId === 'object' + ? (sessionOrId.id || sessionOrId.sessionId) + : sessionOrId; + const normalized = String(rawId || '').trim(); + if (!normalized) { + throw new Error('Active practice session requires a stable session id'); + } + return normalized.startsWith('active-session:') ? normalized : `active-session:${normalized}`; + } + + async persistActiveSession(session, previousEntityId = null) { + const entity = Object.assign({}, session, { id: this.activeSessionEntityId(session) }); + const receipt = await window.AppData.recovery.saveActiveSession(entity); + if (previousEntityId && previousEntityId !== entity.id) { + await window.AppData.recovery.discardActiveSession(previousEntityId); + } + return receipt; + } + + resolveAnnotationState(recordData = {}, fallbackSources = []) { + const coreContracts = this.getCoreContracts(); + if (coreContracts && typeof coreContracts.resolveAnnotationState === 'function') { + return coreContracts.resolveAnnotationState(recordData, fallbackSources); + } + const root = recordData && typeof recordData === 'object' ? recordData : {}; + const sources = [root, root.rawData, root.realData, root.rawData?.realData] + .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources]) + .filter((source) => source && typeof source === 'object' && !Array.isArray(source)); + const pickArray = (field) => { + const source = sources.find((candidate) => Array.isArray(candidate[field])); + return source ? this.clonePlainObject(source[field]) : []; + }; + const pickString = (field) => { + const source = sources.find((candidate) => typeof candidate[field] === 'string'); + return source ? source[field] : ''; + }; + const scrollSource = sources.find((candidate) => candidate.scrollY != null && Number.isFinite(Number(candidate.scrollY))); + return { + highlights: pickArray('highlights'), + markedQuestions: pickArray('markedQuestions'), + noteText: pickString('noteText'), + notes: pickArray('notes'), + noteOutlines: pickArray('noteOutlines'), + scrollY: scrollSource ? Number(scrollSource.scrollY) : 0 + }; + } + firstFiniteNumber(fallback, ...values) { for (const value of values) { if (value === undefined || value === null) { @@ -5965,8 +4078,6 @@ class PracticeRecorder { async recordRejectedCompletionPayload(payload, context = {}) { try { - const existing = await this.metaRepo.get('rejected_completion_payloads', []); - const list = Array.isArray(existing) ? existing : []; const snapshot = { id: `rejected_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, createdAt: new Date().toISOString(), @@ -5983,54 +4094,33 @@ class PracticeRecorder { } : null }; - list.unshift(snapshot); - if (list.length > 50) { - list.splice(50); + await window.AppData.recovery.saveRejectedCompletion(snapshot); + const existing = await window.AppData.recovery.listRejectedCompletions(); + const list = (Array.isArray(existing) ? existing : []) + .slice() + .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0)); + for (const stale of list.slice(50)) { + await window.AppData.recovery.discardRejectedCompletion(stale.id || stale.sessionId || stale.recordId); } - await this.metaRepo.set('rejected_completion_payloads', list); } catch (error) { console.warn('[PracticeRecorder] 记录拒绝的完成负载失败:', error); } } - lookupExamIndexEntry(examId) { + lookupExamIndexEntry(examId, examIndex = []) { if (!examId) return null; - if (this.practiceTypeCache.has(examId)) { - return this.practiceTypeCache.get(examId); - } - - const sources = [ - () => Array.isArray(window.examIndex) ? window.examIndex : null, - () => typeof window.getReadingExamIndex === 'function' - ? window.getReadingExamIndex().map(exam => ({ ...exam, type: exam.type || 'reading' })) - : null, - () => Array.isArray(window.__READING_EXAM_INDEX__) - ? window.__READING_EXAM_INDEX__.map(exam => ({ ...exam, type: exam.type || 'reading' })) - : null, - () => Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : null - ]; - - for (const getSource of sources) { - const list = getSource(); - if (Array.isArray(list)) { - const entry = list.find(item => item && item.id === examId); - if (entry) { - this.practiceTypeCache.set(examId, entry); - return entry; - } - } - } - - this.practiceTypeCache.set(examId, null); - return null; + const entry = (Array.isArray(examIndex) ? examIndex : []) + .find(item => item && item.id === examId) || null; + if (entry) this.practiceTypeCache.set(examId, entry); + return entry; } resolvePracticeType(session = {}, examEntry = null) { const examId = session.examId; const metadata = session.metadata || {}; const cachedEntry = this.practiceTypeCache.get(examId); - const entry = examEntry || cachedEntry || this.lookupExamIndexEntry(examId); + const entry = examEntry || cachedEntry || null; const normalized = this.normalizePracticeType( metadata.type @@ -6186,12 +4276,16 @@ class PracticeRecorder { * 恢复活动会话 */ async restoreActiveSessions() { - const raw = await this.metaRepo.get('active_sessions', []); + const raw = await window.AppData.recovery.listActiveSessions(); const storedSessions = Array.isArray(raw) ? raw : []; - storedSessions.forEach(sessionData => { + storedSessions + .slice() + .sort((left, right) => Date.parse(left.updatedAt || left.lastActivity || 0) - Date.parse(right.updatedAt || right.lastActivity || 0)) + .forEach(sessionData => { this.activeSessions.set(sessionData.examId, { ...sessionData, + id: this.activeSessionEntityId(sessionData), status: 'restored', lastActivity: new Date().toISOString() }); @@ -6227,6 +4321,13 @@ class PracticeRecorder { } const { type, data } = normalized; + // Completion persistence belongs exclusively to the exam host protocol. The + // recorder is invoked there only after source/origin/token validation, so a + // second global listener must never race it into a duplicate save. + if (type === 'session_completed') { + return; + } + switch (type) { case 'session_started': this.handleSessionStarted(data); @@ -6234,11 +4335,6 @@ class PracticeRecorder { case 'session_progress': this.handleSessionProgress(data); break; - case 'session_completed': - this.handleSessionCompleted(data).catch(error => { - console.error('[PracticeRecorder] 会话完成处理失败:', error); - }); - break; case 'session_paused': this.handleSessionPaused(data); break; @@ -6340,18 +4436,7 @@ class PracticeRecorder { normalizedComparison ); const answerList = this.convertAnswerMapToArray(answerMap, correctAnswerMap); - const highlights = Array.isArray(payload.highlights) - ? payload.highlights.slice() - : (Array.isArray(payload.realData?.highlights) ? payload.realData.highlights.slice() : []); - const markedQuestions = Array.isArray(payload.markedQuestions) - ? payload.markedQuestions.slice() - : (Array.isArray(payload.realData?.markedQuestions) ? payload.realData.markedQuestions.slice() : []); - const scrollY = Number.isFinite(Number(payload.scrollY)) - ? Number(payload.scrollY) - : (Number.isFinite(Number(payload.realData?.scrollY)) ? Number(payload.realData.scrollY) : 0); - const noteText = typeof payload.noteText === 'string' - ? payload.noteText - : (typeof payload.realData?.noteText === 'string' ? payload.realData.noteText : ''); + const annotations = this.resolveAnnotationState(payload); const questionTypeMap = payload.questionTypeMap && typeof payload.questionTypeMap === 'object' ? { ...payload.questionTypeMap } : (payload.realData?.questionTypeMap && typeof payload.realData.questionTypeMap === 'object' @@ -6409,15 +4494,12 @@ class PracticeRecorder { answerComparison: normalizedComparison, questionTypePerformance: payload.questionTypePerformance || {}, interactions: payload.interactions || [], - highlights, - scrollY, - markedQuestions, - noteText, + ...annotations, questionTypeMap, startTime: payload.startTime || null, endTime: payload.endTime || null, metadata: Object.assign({}, payload.metadata || {}, { - markedQuestions: markedQuestions.slice() + markedQuestions: this.clonePlainObject(annotations.markedQuestions) }), source: scoreInfo.source || payload.pageType || 'practice_page', realData: Object.assign({}, payload.realData || {}, { @@ -6425,10 +4507,7 @@ class PracticeRecorder { correctAnswers: correctAnswerMap, correctAnswerMap, answerComparison: normalizedComparison, - highlights, - scrollY, - markedQuestions, - noteText, + ...this.clonePlainObject(annotations), questionTypeMap, scoreInfo: Object.assign({}, scoreInfo, { details: answerDetails }) }) @@ -6546,35 +4625,61 @@ class PracticeRecorder { * 开始练习会话 */ startPracticeSession(examId, examData = {}) { - const sessionId = this.generateSessionId(); - const startTime = new Date().toISOString(); + const requestedSessionId = examData && examData.sessionId != null + ? String(examData.sessionId).trim() + : ''; + const existing = this.activeSessions.has(examId) + ? this.activeSessions.get(examId) + : null; + // Prefer an explicit host session id so INIT/COMPLETE and the recorder share one + // identity. Reuse an existing active session when the host rebinds the same exam. + const sessionId = requestedSessionId + || (existing && existing.sessionId) + || this.generateSessionId(examId); + const startTime = (existing && existing.startTime) + || new Date().toISOString(); + const previousEntityId = existing + ? this.activeSessionEntityId(existing) + : null; const sessionData = { + id: this.activeSessionEntityId(sessionId), sessionId, examId, startTime, - lastActivity: startTime, - status: 'started', - progress: { + lastActivity: new Date().toISOString(), + status: existing ? (existing.status || 'started') : 'started', + progress: Object.assign({ currentQuestion: 0, totalQuestions: examData.totalQuestions || 0, answeredQuestions: 0, timeSpent: 0 - }, - answers: [], - metadata: { + }, existing && existing.progress ? existing.progress : {}), + answers: existing && existing.answers ? existing.answers : [], + metadata: Object.assign({ examTitle: examData.title || '', category: examData.category || '', frequency: examData.frequency || '', userAgent: navigator.userAgent, screenResolution: `${screen.width}x${screen.height}`, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone - } + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + // 启动时捕获的题库配置 ID:mixin/调用方传入则写进会话 metadata,后续经 + // handleSessionCompleted 的 buildRecordMetadata 透传到记录 metadata。 + libraryConfigurationId: (examData && examData.libraryConfigurationId != null) + ? examData.libraryConfigurationId + : null + }, existing && existing.metadata ? existing.metadata : {}) }; + if (examData && examData.libraryConfigurationId != null) { + sessionData.metadata.libraryConfigurationId = examData.libraryConfigurationId; + } + if (examData && examData.title) { + sessionData.metadata.examTitle = examData.title; + } // 存储会话 this.activeSessions.set(examId, sessionData); - this.saveActiveSessions().catch(error => { + this.persistActiveSession(sessionData, previousEntityId).catch(error => { console.error('[PracticeRecorder] 保存活动会话失败:', error); }); @@ -6593,25 +4698,55 @@ class PracticeRecorder { * 处理会话开始 */ handleSessionStarted(data) { - const { examId, sessionId, metadata } = data; + const examId = data && data.examId != null ? String(data.examId).trim() : ''; + const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : ''; + const metadata = data && data.metadata && typeof data.metadata === 'object' + ? data.metadata + : null; - if (this.activeSessions.has(examId)) { - let session = this.activeSessions.get(examId); - session.sessionId = sessionId; - session.status = 'active'; - session.lastActivity = new Date().toISOString(); + if (!examId || !sessionId) { + return; + } - if (metadata) { - session.metadata = { ...session.metadata, ...metadata }; + // Host handshake (SESSION_READY / INIT rebind) must create the active session when + // the full PracticeRecorder was hot-upgraded after a fallback start, or when the + // early startPracticeSession raced ahead of the host expectedSessionId. + if (!this.activeSessions.has(examId)) { + this.startPracticeSession(examId, Object.assign({}, metadata || {}, { + sessionId, + title: metadata && (metadata.title || metadata.examTitle) || '', + category: metadata && metadata.category || '', + frequency: metadata && metadata.frequency || '', + libraryConfigurationId: metadata && metadata.libraryConfigurationId != null + ? metadata.libraryConfigurationId + : null + })); + const created = this.activeSessions.get(examId); + if (created) { + created.status = 'active'; + this.activeSessions.set(examId, created); } + console.log(`Session created on host confirm: ${examId}`); + return; + } - this.activeSessions.set(examId, session); - this.saveActiveSessions().catch(error => { - console.error('[PracticeRecorder] 保存活动会话失败:', error); - }); + let session = this.activeSessions.get(examId); + const previousEntityId = this.activeSessionEntityId(session); + session.sessionId = sessionId; + session.id = this.activeSessionEntityId(sessionId); + session.status = 'active'; + session.lastActivity = new Date().toISOString(); - console.log(`Session confirmed started: ${examId}`); + if (metadata) { + session.metadata = { ...session.metadata, ...metadata }; } + + this.activeSessions.set(examId, session); + this.persistActiveSession(session, previousEntityId).catch(error => { + console.error('[PracticeRecorder] 保存活动会话失败:', error); + }); + + console.log(`Session confirmed started: ${examId}`); } /** @@ -6649,6 +4784,7 @@ class PracticeRecorder { } const { results } = payload; + const examIndex = await window.resolveActiveLibraryIndex(); const candidateExamIds = [ payload.examId, payload.originalExamId, @@ -6723,9 +4859,9 @@ class PracticeRecorder { session.startTime = resolvedStartTime; - const examEntry = this.lookupExamIndexEntry(resolvedExamId) - || this.lookupExamIndexEntry(payload.originalExamId) - || this.lookupExamIndexEntry(payload.derivedExamId); + const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex) + || this.lookupExamIndexEntry(payload.originalExamId, examIndex) + || this.lookupExamIndexEntry(payload.derivedExamId, examIndex); const type = this.resolvePracticeType({ ...session, examId: resolvedExamId }, examEntry); const recordDate = this.resolveRecordDate({ ...session, endTime: resolvedEndTime }, resolvedEndTime); let metadata = this.buildRecordMetadata( @@ -6805,6 +4941,8 @@ class PracticeRecorder { results?.accuracy, scoreInfo.accuracy ); + const annotations = this.resolveAnnotationState(results || {}, [session || {}]); + metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions); const practiceRecord = { id: `record_${session.sessionId || this.generateSessionId(resolvedExamId)}`, @@ -6826,6 +4964,7 @@ class PracticeRecorder { correctAnswerMap, scoreInfo, questionTypePerformance: results?.questionTypePerformance || {}, + ...annotations, metadata, suiteSessionId, createdAt: resolvedEndTime, @@ -6836,7 +4975,8 @@ class PracticeRecorder { scoreInfo, interactions: results?.interactions || [], isRealData: true, - source: results?.source || 'practice_page' + source: results?.source || 'practice_page', + ...this.clonePlainObject(annotations) }) }; @@ -6863,7 +5003,7 @@ class PracticeRecorder { } try { - const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord; + const savedRecord = await this.savePracticeRecord(practiceRecord); if (!syntheticSession && this.activeSessions.has(resolvedExamId)) { this.endPracticeSession(resolvedExamId); @@ -6876,11 +5016,13 @@ class PracticeRecorder { return savedRecord; } catch (error) { console.error('[PracticeRecorder] 处理完成会话时出错:', error); - await this.saveToTemporaryStorage(practiceRecord); - if (!syntheticSession && this.activeSessions.has(resolvedExamId)) { - this.endPracticeSession(resolvedExamId, 'save_failed'); + try { + await this.saveToTemporaryStorage(practiceRecord); + } catch (recoveryError) { + console.error('[PracticeRecorder] canonical 与 recovery 提交均失败:', recoveryError); + error.recoveryError = recoveryError; } - return practiceRecord; + throw error; } } @@ -6990,6 +5132,7 @@ class PracticeRecorder { if (!this.activeSessions.has(examId)) return; let session = this.activeSessions.get(examId); + const sessionEntityId = this.activeSessionEntityId(session); // 如果会话未完成,创建中断记录 if (reason !== 'completed' && session.status !== 'completed') { @@ -7019,8 +5162,8 @@ class PracticeRecorder { // 清理会话 this.activeSessions.delete(examId); this.cleanupSessionListener(examId); - this.saveActiveSessions().catch(error => { - console.error('[PracticeRecorder] 保存活动会话失败:', error); + window.AppData.recovery.discardActiveSession(sessionEntityId).catch(error => { + console.error('[PracticeRecorder] 清理活动会话失败:', error); }); console.log(`Practice session ended: ${examId} (${reason})`); @@ -7097,59 +5240,49 @@ class PracticeRecorder { * 保存所有会话 */ async saveAllSessions() { - try { - await this.saveActiveSessions(); - console.log('Auto-saved all active sessions'); - } catch (error) { - console.error('[PracticeRecorder] 保存活动会话失败:', error); - } + await this.saveActiveSessions(); + console.log('Auto-saved all active sessions'); } /** * 保存活动会话到存储 */ async saveActiveSessions() { - const sessionsArray = Array.from(this.activeSessions.values()); - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') { - await practiceCoreStore.writeMeta('active_sessions', sessionsArray); - return; + for (const session of this.activeSessions.values()) { + await this.persistActiveSession(session); } - await this.metaRepo.set('active_sessions', sessionsArray); } /** * 保存练习记录 */ - async savePracticeRecord(record) { + async savePracticeRecord(record, options = {}) { const maxRetries = 3; const storageReadyRecord = this.prepareRecordForStorage(record); + const saveOperationId = storageReadyRecord.operationId || this.generateOperationId('practice-complete'); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`[PracticeRecorder] 开始保存练习记录(尝试 ${attempt}/${maxRetries}):`, record.id); - const practiceRecordApi = window.PracticeRecordAPI; - if (!practiceRecordApi || typeof practiceRecordApi.saveRecord !== 'function') { - throw new Error('PracticeRecordAPI not available'); - } - - const savedRawRecord = await practiceRecordApi.saveRecord(storageReadyRecord, { - updateStats: true + const receipt = await window.AppData.practice.completeAttempt({ + record: storageReadyRecord, + operationId: saveOperationId }); + const savedRawRecord = receipt.record; const savedRecord = this.restoreRecordAnswerState(savedRawRecord, record); - console.log(`[PracticeRecorder] PracticeRecordAPI 保存成功: ${savedRecord.id}`); + console.log(`[PracticeRecorder] AppData.practice 保存成功: ${savedRecord.id}`); const verified = await this.verifyRecordSaved(savedRecord.id); if (!verified) { - console.warn('[PracticeRecorder] PracticeRecordAPI 保存后未立即检出,稍后将由同步任务纠正'); + console.warn('[PracticeRecorder] AppData.practice 保存后未立即检出,稍后将由同步任务纠正'); } else { console.log('[PracticeRecorder] 记录保存验证成功'); } return savedRecord; } catch (error) { console.error( - `[PracticeRecorder] PracticeRecordAPI 保存失败 (尝试 ${attempt}):`, + `[PracticeRecorder] AppData.practice 保存失败 (尝试 ${attempt}):`, { error: error?.message, validationErrors: error?.validationErrors || null, @@ -7159,7 +5292,7 @@ class PracticeRecorder { ); if (attempt === maxRetries || this.isCriticalError(error)) { - return await this.retrySaveWithStandardizedRecord(record); + return await this.retrySaveWithStandardizedRecord(record, saveOperationId); } const delay = attempt * 100; @@ -7168,46 +5301,43 @@ class PracticeRecorder { } } - return await this.retrySaveWithStandardizedRecord(record); + return await this.retrySaveWithStandardizedRecord(record, saveOperationId); } /** * 用标准化后的 payload 再走统一 API 保存。 */ - async retrySaveWithStandardizedRecord(record) { + async retrySaveWithStandardizedRecord(record, operationId = null) { try { console.log('[PracticeRecorder] 使用标准化记录重试保存'); - const standardizedRecord = this.normalizeRecordForPracticeRecordApi(record); - const practiceRecordApi = window.PracticeRecordAPI; - if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') { - return await practiceRecordApi.saveRecord(standardizedRecord, { - updateStats: true - }); - } - - throw new Error('PracticeRecordAPI unavailable'); + const examIndex = await window.resolveActiveLibraryIndex(); + const standardizedRecord = this.normalizeRecordForAppData(record, examIndex); + const receipt = await window.AppData.practice.completeAttempt({ + record: standardizedRecord, + operationId: operationId || standardizedRecord.operationId || this.generateOperationId('practice-complete') + }); + return receipt.record; } catch (error) { console.error('[PracticeRecorder] 标准化重试保存失败:', { error: error?.message, validationErrors: error?.validationErrors || null, recordSummary: this.buildRecordLogSummary(record) }, error); - await this.saveToTemporaryStorage(record); - throw new Error(`All save methods failed: ${error.message}`); + throw error; } } /** * 标准化记录格式(用于统一 API 重试保存)。 */ - normalizeRecordForPracticeRecordApi(recordData) { + normalizeRecordForAppData(recordData, examIndex = []) { const now = new Date().toISOString(); const resolvedExamId = this.inferExamId(recordData); const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime()) ? new Date(recordData.endTime).toISOString() : now; - const examEntry = this.lookupExamIndexEntry(resolvedExamId); + const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex); const inferredType = this.normalizePracticeType( recordData.type || recordData.metadata?.type @@ -7267,6 +5397,8 @@ class PracticeRecorder { recordData.realData?.scoreInfo?.score, recordData.score ); + const annotations = this.resolveAnnotationState(recordData, [recordData.metadata || {}]); + metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions); return { // 基础信息 @@ -7296,11 +5428,13 @@ class PracticeRecorder { correctAnswerMap, scoreInfo: Object.assign({}, recordData.scoreInfo || {}, { details: answerDetails }), questionTypePerformance: recordData.questionTypePerformance || {}, + ...annotations, realData: Object.assign({}, recordData.realData || {}, { answers: answerMap, correctAnswers: correctAnswerMap, correctAnswerMap, - scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails }) + scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails }), + ...this.clonePlainObject(annotations) }), // 元数据 @@ -7318,17 +5452,7 @@ class PracticeRecorder { */ async verifyRecordSaved(recordId) { try { - const practiceRecordApi = window.PracticeRecordAPI; - if (practiceRecordApi && typeof practiceRecordApi.getById === 'function') { - const record = await practiceRecordApi.getById(recordId); - return !!record; - } - if (practiceRecordApi && typeof practiceRecordApi.list === 'function') { - const records = await practiceRecordApi.list(); - const list = Array.isArray(records) ? records : []; - return list.some(r => r && (r.id === recordId || r.sessionId === recordId)); - } - return false; + return Boolean(await window.AppData.practice.get(recordId, { projection: 'light' })); } catch (error) { console.error('[PracticeRecorder] 验证记录保存时出错', error); return false; @@ -7390,11 +5514,23 @@ class PracticeRecorder { this.convertComparisonToAnswerMap(record.answerComparison || record.realData?.answerComparison, 'userAnswer') ); const correctMap = this.resolveRecordCorrectAnswerMap(record); + const annotations = this.resolveAnnotationState(record, [record.metadata || {}]); const answerList = this.convertAnswerMapToArray(answerMap, correctMap); clone.answerList = answerList; - clone.answers = answerList; + // AppData v2 stores canonical answer maps in the detail entity. Converting + // `answers` to the legacy array shape here makes persisted review records + // unreadable to consumers that intentionally accept maps only. + clone.answers = answerMap; clone.correctAnswerMap = correctMap; + clone.questionTypeMap = this.clonePlainObject( + record.questionTypeMap || record.realData?.questionTypeMap || {} + ); + clone.interactions = this.clonePlainObject( + Array.isArray(record.interactions) + ? record.interactions + : (Array.isArray(record.realData?.interactions) ? record.realData.interactions : []) + ); clone.answerDetails = this.buildCanonicalAnswerDetails( answerMap, correctMap, @@ -7404,6 +5540,10 @@ class PracticeRecorder { record.answerComparison || record.realData?.answerComparison ); clone.scoreInfo = Object.assign({}, clone.scoreInfo || {}, { details: clone.answerDetails }); + Object.assign(clone, this.clonePlainObject(annotations)); + clone.metadata = Object.assign({}, clone.metadata || {}, { + markedQuestions: this.clonePlainObject(annotations.markedQuestions) + }); if (clone.answerComparison) { clone.answerComparison = this.normalizeAnswerComparison(clone.answerComparison); @@ -7413,7 +5553,8 @@ class PracticeRecorder { answers: answerMap, correctAnswers: correctMap, correctAnswerMap: correctMap, - scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails }) + scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails }), + ...this.clonePlainObject(annotations) }); if (clone.realData.answerComparison) { clone.realData.answerComparison = this.normalizeAnswerComparison(clone.realData.answerComparison); @@ -7460,6 +5601,9 @@ class PracticeRecorder { correctAnswerMap: clone.correctAnswerMap, scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details }) }); + const annotations = this.resolveAnnotationState(clone, [sourceRecord || {}]); + Object.assign(clone, this.clonePlainObject(annotations)); + clone.realData = Object.assign({}, clone.realData, this.clonePlainObject(annotations)); return clone; } @@ -7480,42 +5624,43 @@ class PracticeRecorder { * 保存到临时存储 */ async saveToTemporaryStorage(record) { - try { - const existing = await this.metaRepo.get('temp_practice_records', []); - const tempRecords = Array.isArray(existing) ? [...existing] : []; - tempRecords.push({ - ...record, - tempSavedAt: new Date().toISOString(), - needsRecovery: true - }); - - // 限制临时记录数量 - const finalTempRecords = tempRecords.length > 50 ? tempRecords.slice(-50) : tempRecords; + const recordId = String(record && (record.id || record.sessionId) || `record-${Date.now()}`); + const receipt = await window.AppData.recovery.saveDraft({ + id: `practice-record:${recordId}`, + recordId, + kind: 'practice_record_recovery', + record: this.clonePlainObject(record), + tempSavedAt: new Date().toISOString(), + needsRecovery: true + }); - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') { - await practiceCoreStore.writeMeta('temp_practice_records', finalTempRecords); - } else { - await this.metaRepo.set('temp_practice_records', finalTempRecords); + try { + const drafts = await window.AppData.recovery.listDrafts(); + const recoveryDrafts = (Array.isArray(drafts) ? drafts : []) + .filter((draft) => draft && draft.kind === 'practice_record_recovery') + .sort((left, right) => Date.parse(left.updatedAt || left.tempSavedAt || 0) - Date.parse(right.updatedAt || right.tempSavedAt || 0)); + for (const stale of recoveryDrafts.slice(0, Math.max(0, recoveryDrafts.length - 50))) { + await window.AppData.recovery.discardDraft(stale.id); } - console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id); - } catch (error) { - console.error('[PracticeRecorder] 临时存储也失败', error); + console.warn('[PracticeRecorder] recovery 草稿清理失败,不影响已提交草稿:', error); } + console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id); + return receipt; } /** * 保存中断记录 */ async saveInterruptedRecord(record) { - const existing = await this.metaRepo.get('interrupted_records', []); - const records = Array.isArray(existing) ? [...existing] : []; - records.push(record); - - const finalRecords = records.length > 100 ? records.slice(-100) : records; - - await this.metaRepo.set('interrupted_records', finalRecords); + await window.AppData.recovery.saveInterrupted(record); + const existing = await window.AppData.recovery.listInterrupted(); + const records = (Array.isArray(existing) ? existing : []) + .slice() + .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0)); + for (const stale of records.slice(100)) { + await window.AppData.recovery.discardInterrupted(stale.id || stale.sessionId || stale.recordId); + } console.log(`Interrupted record saved: ${record.id}`); } @@ -7523,33 +5668,12 @@ class PracticeRecorder { * 更新用户统计 */ async updateUserStats(practiceRecord) { - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.recalculateStats !== 'function') { - throw new Error('PracticeRecordAPI.recalculateStats unavailable'); - } - await window.PracticeRecordAPI.recalculateStats(); - console.log('User stats recalculated through PracticeRecordAPI'); + await window.AppData.practice.getStats(); } async listPracticeRecordsForStats() { - // 统计读取只需元数据字段,使用轻量 listSummary 避免反序列化+克隆完整记录 - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') { - try { - const records = await window.PracticeRecordAPI.listSummary(); - return Array.isArray(records) ? records : []; - } catch (error) { - console.warn('[PracticeRecorder] PracticeRecordAPI.listSummary 统计读取失败:', error); - } - } - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - try { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } catch (error) { - console.warn('[PracticeRecorder] PracticeRecordAPI.list 统计读取失败:', error); - } - } - - return []; + const records = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(records) ? records : []; } /** @@ -7564,11 +5688,10 @@ class PracticeRecorder { */ async getPracticeRecords(filters = {}) { try { - const practiceRecordApi = window.PracticeRecordAPI; - if (!practiceRecordApi || typeof practiceRecordApi.list !== 'function') { - return []; - } - const records = await practiceRecordApi.list(); + // 过滤条件(examId/metadata.category/startTime/date/accuracy)与唯一内部消费者 + // getDataIntegrityReport -> validateRecordIntegrity(id/examId/startTime/endTime/accuracy/duration) + // 都在 light 投影覆盖范围内,不需要拉取答题详情。 + const records = await window.AppData.practice.list({ projection: 'light' }); const list = Array.isArray(records) ? records : []; if (Object.keys(filters).length === 0) { return list; @@ -7584,15 +5707,12 @@ class PracticeRecorder { return true; }); } catch (error) { - console.error('Failed to get practice records from PracticeRecordAPI:', error); + console.error('Failed to get practice records from AppData.practice:', error); return []; } } getDefaultUserStats() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getDefaultStats === 'function') { - return window.PracticeRecordAPI.getDefaultStats(); - } return { totalPractices: 0, totalTimeSpent: 0, @@ -7606,13 +5726,6 @@ class PracticeRecorder { }; } - getUnifiedBackupManager() { - if (window.DataBackupManager) { - return new window.DataBackupManager(); - } - throw new Error('DataBackupManager unavailable'); - } - convertRecordsToCSV(records) { const list = Array.isArray(records) ? records : []; if (list.length === 0) return ''; @@ -7648,10 +5761,7 @@ class PracticeRecorder { * 获取用户统计 */ async getUserStats() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - return await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() }); - } - return this.getDefaultUserStats(); + return Object.assign(this.getDefaultUserStats(), await window.AppData.practice.getStats()); } /** @@ -7659,47 +5769,53 @@ class PracticeRecorder { */ async exportData(format = 'json') { const normalizedFormat = String(format || 'json').toLowerCase(); - // CSV 导出只需元数据字段,使用轻量 listSummary 避免加载完整记录 - const records = normalizedFormat === 'csv' - ? await this.listPracticeRecordsForStats() - : await this.getPracticeRecords(); if (normalizedFormat === 'csv') { + const records = await this.listPracticeRecordsForStats(); return this.convertRecordsToCSV(records); } if (normalizedFormat !== 'json') { throw new Error(`Unsupported export format: ${format}`); } - return JSON.stringify({ - exportDate: new Date().toISOString(), - version: PRACTICE_RECORDER_EXPORT_VERSION, - practiceRecords: records, - userStats: await this.getUserStats() - }, null, 2); + const snapshot = await window.AppData.backups.export({ domains: ['practice'] }); + return JSON.stringify(snapshot, null, 2); } /** * 导入练习数据 */ - importData(data, options = {}) { - const manager = this.getUnifiedBackupManager(); + async importData(data, options = {}) { const mergeMode = options.merge === false || options.mergeMode === 'replace' ? 'replace' : (options.mergeMode || 'merge'); - return manager.importPracticeData(data, Object.assign({}, options, { mergeMode })); + const backup = options.createBackup === false + ? null + : await window.AppData.backups.create({ type: 'pre-import' }); + const payload = Array.isArray(data) ? { records: data } : data; + const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mergeMode }); + const receipt = await window.AppData.backups.commitImport(preview.id, { + operationId: options.operationId, + confirmDestructive: mergeMode === 'replace' + }); + try { + await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup && backup.id, practice: preview.practice }); + } catch (historyError) { + console.warn('[PracticeRecorder] 导入已提交,但历史记录写入失败:', historyError); + } + return Object.assign({}, receipt, { backupId: backup && backup.id }); } /** * 创建数据备份 */ createBackup(backupName = null) { - return this.getUnifiedBackupManager().createBackup(backupName, 'practice_recorder'); + return window.AppData.backups.create({ id: backupName || undefined, type: 'practice-recorder' }); } /** * 恢复数据备份 */ restoreBackup(backupId) { - return this.getUnifiedBackupManager().restoreBackup(backupId); + return window.AppData.backups.restore(backupId); } /** @@ -7707,10 +5823,7 @@ class PracticeRecorder { */ getBackups() { try { - if (window.BackupAPI && typeof window.BackupAPI.list === 'function') { - return window.BackupAPI.list(); - } - return this.scoreStorage.getBackups(); + return window.AppData.backups.list(); } catch (error) { console.error('Failed to get backups:', error); return []; @@ -7722,7 +5835,7 @@ class PracticeRecorder { */ getStorageStats() { try { - return this.scoreStorage.getStorageStats(); + return window.AppData.status(); } catch (error) { console.error('Failed to get storage stats:', error); return null; @@ -7730,17 +5843,32 @@ class PracticeRecorder { } generateRecordId() { - if (this.scoreStorage && typeof this.scoreStorage.generateRecordId === 'function') { - return this.scoreStorage.generateRecordId(); - } return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } + generateOperationId(prefix = 'operation') { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return `${prefix}_${window.crypto.randomUUID()}`; + } + } catch (_) { + // fall through to timestamp entropy + } + return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 12)}`; + } + /** - * 生成会话ID + * 生成会话ID(可选带 examId 前缀,便于与宿主 expectedSessionId 对齐) */ - generateSessionId() { - return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + generateSessionId(examId) { + const suffix = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const normalizedExamId = typeof examId === 'string' + ? examId.trim().replace(/\s+/g, '-') + : (examId != null ? String(examId).trim().replace(/\s+/g, '-') : ''); + if (normalizedExamId) { + return `${normalizedExamId}_${suffix}`; + } + return `session_${suffix}`; } extractExamIdFromRecordId(recordId) { @@ -7790,8 +5918,8 @@ class PracticeRecorder { } // 获取题目信息 - const examIndex = await this.metaRepo.get('exam_index', []); - const examList = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []); + const examIndex = await window.resolveActiveLibraryIndex(); + const examList = Array.isArray(examIndex) ? examIndex : []; const exam = examList.find(e => e.id === examId); if (!exam) { @@ -7802,12 +5930,11 @@ class PracticeRecorder { // 构造增强的练习记录 const practiceRecord = this.createRealPracticeRecord(exam, validatedData); - // 保存记录 - 这里ScoreStorage会自动更新用户统计 - const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord; + // AppData 在权威提交后调度统计投影。 + const savedRecord = await this.savePracticeRecord(practiceRecord); // 清理活动会话 - this.activeSessions.delete(examId); - await this.saveActiveSessions(); + this.endPracticeSession(examId); // 触发完成事件 this.dispatchSessionEvent('realDataProcessed', { @@ -7912,13 +6039,10 @@ class PracticeRecorder { ); const totalQuestions = scoreInfo.total || Object.keys(correctAnswerMap).length || Object.keys(answerMap).length; const accuracy = scoreInfo.accuracy || (totalQuestions > 0 ? score / totalQuestions : 0); - const highlights = Array.isArray(realData.highlights) ? realData.highlights.slice() : []; - const markedQuestions = Array.isArray(realData.markedQuestions) ? realData.markedQuestions.slice() : []; - const scrollY = Number.isFinite(Number(realData.scrollY)) ? Number(realData.scrollY) : 0; - const noteText = typeof realData.noteText === 'string' ? realData.noteText : ''; + const annotations = this.resolveAnnotationState(realData); const practiceRecord = { - // 基础信息 - 与ScoreStorage兼容 + // 基础信息 id: recordId, examId: exam.id, sessionId: realData.sessionId, @@ -7936,30 +6060,40 @@ class PracticeRecorder { correctAnswers: score, // 正确答案数等于分数 accuracy: accuracy, - // 答题详情 - 转换为ScoreStorage期望的格式 + // 答题详情 answers: answerList, correctAnswerMap, answerComparison, questionTypeMap, questionTypePerformance: this.extractQuestionTypePerformance(realData), - highlights, - scrollY, - markedQuestions, - noteText, + ...annotations, - // 元数据 - 与ScoreStorage兼容 + // 元数据 metadata: { examTitle: exam.title || '', category: exam.category || '', frequency: exam.frequency || '', - markedQuestions: markedQuestions.slice(), + markedQuestions: this.clonePlainObject(annotations.markedQuestions), collectionMethod: 'automatic', dataQuality: this.assessDataQuality(realData), - processingTime: Date.now() + processingTime: Date.now(), + // 启动时捕获的题库配置 ID:优先取 realData 与其 metadata 显式透传的值; + // 若上游未透传则显式写入 null(保留 key),让 AppData 记录 provenance + // 不再回退读取当前激活题库,避免记录来源在提交时被切换题库影响。 + libraryConfigurationId: (realData + && realData.libraryConfigurationId !== undefined + && realData.libraryConfigurationId !== null) + ? realData.libraryConfigurationId + : (realData + && realData.metadata + && realData.metadata.libraryConfigurationId !== undefined + && realData.metadata.libraryConfigurationId !== null) + ? realData.metadata.libraryConfigurationId + : null }, // 额外的真实数据信息 - realData: { + realData: Object.assign({}, realData, { sessionId: realData.sessionId, answers: answerMap, correctAnswers: correctAnswerMap, @@ -7968,15 +6102,12 @@ class PracticeRecorder { questionTypeMap, answerHistory: realData.answerHistory || {}, interactions: realData.interactions || [], - highlights, - scrollY, - markedQuestions, - noteText, + ...this.clonePlainObject(annotations), scoreInfo: scoreInfo, pageType: realData.pageType, url: realData.url, source: scoreInfo.source || 'data_collector' - }, + }), // 系统信息 dataSource: 'real', @@ -7988,7 +6119,7 @@ class PracticeRecorder { } /** - * 转换答案格式为ScoreStorage兼容格式 + * 转换答案格式为 canonical record 格式 */ convertAnswersFormat(answers, correctAnswerMap = {}, answerComparison = {}, questionTypeMap = {}) { if (!answers || typeof answers !== 'object') { @@ -8183,7 +6314,7 @@ class PracticeRecorder { sessionId: sessionId, timestamp: Date.now() } - }, '*'); + }, window.location.protocol === 'file:' ? '*' : window.location.origin); } } @@ -8192,8 +6323,11 @@ class PracticeRecorder { */ async recoverTemporaryRecords() { try { - const tempRecords = await this.metaRepo.get('temp_practice_records', []); - const list = Array.isArray(tempRecords) ? tempRecords : []; + const tempRecords = await window.AppData.recovery.listDrafts(); + const list = (Array.isArray(tempRecords) ? tempRecords : []).filter((draft) => ( + draft + && (draft.kind === 'practice_record_recovery' || draft.needsRecovery === true) + )); if (list.length === 0) { console.log('[PracticeRecorder] 没有需要恢复的临时记录'); @@ -8203,12 +6337,12 @@ class PracticeRecorder { console.log(`[PracticeRecorder] 发现 ${list.length} 条临时记录,开始恢复`); let recoveredCount = 0; - const failedRecords = []; - for (const tempRecord of list) { try { - // 移除临时标识 - const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord; + const sourceRecord = tempRecord.record && typeof tempRecord.record === 'object' + ? tempRecord.record + : tempRecord; + const { tempSavedAt, needsRecovery, kind, ...cleanRecord } = sourceRecord; const sanitized = this.sanitizeRecoveredRecord(cleanRecord); if (!sanitized) { console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord?.id); @@ -8217,34 +6351,16 @@ class PracticeRecorder { // 尝试正常保存 await this.savePracticeRecord(sanitized); + await window.AppData.recovery.discardDraft(tempRecord.id); recoveredCount++; console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized.id}`); } catch (error) { console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord.id}`, error); - failedRecords.push(tempRecord); - } - } - - // 清理已恢复的临时记录 - if (failedRecords.length === 0) { - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.removeMeta === 'function') { - await practiceCoreStore.removeMeta('temp_practice_records'); - } else { - await this.metaRepo.remove('temp_practice_records'); - } - console.log(`[PracticeRecorder] 所有${recoveredCount} 条临时记录恢复成功`); - } else { - const practiceCoreStore = window.PracticeCore && window.PracticeCore.store; - if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') { - await practiceCoreStore.writeMeta('temp_practice_records', failedRecords); - } else { - await this.metaRepo.set('temp_practice_records', failedRecords); } - console.log(`[PracticeRecorder] 恢复了${recoveredCount} 条记录,${failedRecords.length} 条失败`); } + console.log(`[PracticeRecorder] 已恢复 ${recoveredCount} 条临时记录`); } catch (error) { console.error('[PracticeRecorder] 恢复临时记录时出错', error); @@ -8317,7 +6433,7 @@ class PracticeRecorder { }); // 检查临时记录 - const tempRecords = await this.metaRepo.get('temp_practice_records', []); + const tempRecords = await window.AppData.recovery.listDrafts(); const tempList = Array.isArray(tempRecords) ? tempRecords : []; report.temporaryRecords.total = tempList.length; report.temporaryRecords.needsRecovery = tempList.filter(r => r && r.needsRecovery).length; @@ -8337,9 +6453,7 @@ class PracticeRecorder { // 检查存储状态 try { - const storageInfo = window.storage && typeof window.storage.getStorageInfo === 'function' - ? await window.storage.getStorageInfo() - : null; + const storageInfo = window.AppData.status(); report.storage.quota = storageInfo; } catch (error) { report.storage.available = false; @@ -8411,6 +6525,12 @@ class PracticeRecorder { // 确保全局可用 window.PracticeRecorder = PracticeRecorder; +// The practice bundle is loaded on demand and may arrive after the bootstrap +// fallback's bounded polling window. Upgrade immediately when the real class +// becomes available so suite submissions never remain on the light recorder. +if (window.app && typeof window.app.instantiatePracticeRecorder === 'function') { + window.app.instantiatePracticeRecorder(); +} /* ===== bundle provided script markers ===== */ @@ -8421,7 +6541,6 @@ window.PracticeRecorder = PracticeRecorder; "js/utils/markdownExporter.js", "js/components/practiceRecordModal.js", "js/components/practiceHistoryEnhancer.js", - "js/core/scoreStorage.js", "js/utils/answerSanitizer.js", "js/core/practiceRecorder.js" ]); diff --git a/js/bundles/reading-page.bundle.js b/js/bundles/reading-page.bundle.js index 8e8891ce..729ffe42 100644 --- a/js/bundles/reading-page.bundle.js +++ b/js/bundles/reading-page.bundle.js @@ -1,5 +1,3990 @@ /* Generated by scripts/build-bundles.mjs. Do not edit by hand. */ +/* ===== js/data/practiceRecordSource.js ===== */ +/** + * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。 + * + * 背景(本文件存在的理由): + * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样: + * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`; + * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress` + * 只看 `metadata.source === 'onboarding-demo'`。 + * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”, + * 用户会看到自己没做过的题影响了正确率与成就。 + * + * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进 + * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge / + * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用) + * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。 + * + * --------------------------------------------------------------------------- + * 语义(两个维度,任一命中即判为非真实) + * + * 1) dataSource(顶层,回退 metadata.dataSource) + * - 缺失 / null / 空串 => **真实记录** + * - 'real' => 真实记录 + * - 其它任何显式值 => 非真实(演示 / 种子 / 占位) + * + * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin + * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。 + * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。 + * + * 2) metadata.source + * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。 + * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见 + * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector' + * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。 + * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。 + * + * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。 + */ +(function initPracticeRecordSource(global) { + 'use strict'; + + // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次), + // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。 + if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) { + return; + } + + /** 被认可为“真实用户练习”的显式 dataSource 取值。 */ + const REAL_DATA_SOURCES = Object.freeze(['real']); + + /** + * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。 + * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js); + * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。 + */ + const DEMO_SOURCE_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo', + 'demo', + 'e2e-seed', + 'e2e_seed' + ]); + + /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */ + const ONBOARDING_PREVIEW_MARKERS = Object.freeze([ + 'onboarding-demo', + 'onboarding_demo', + 'onboardingdemo' + ]); + + const realDataSourceSet = new Set(REAL_DATA_SOURCES); + const demoSourceSet = new Set(DEMO_SOURCE_MARKERS); + const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS); + + function normalize(value) { + if (value === undefined || value === null) return ''; + return String(value).trim().toLowerCase(); + } + + function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function hasOwn(object, field) { + return Object.prototype.hasOwnProperty.call(object, field); + } + + /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */ + function readDataSource(record) { + if (hasOwn(record, 'dataSource')) return normalize(record.dataSource); + const metadata = asObject(record.metadata); + return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : ''; + } + + function readMetadataSource(record) { + return normalize(asObject(record.metadata).source); + } + + /** + * 唯一判定入口:该记录是否算作用户的真实练习。 + * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它, + * 三处结论一致是本模块的核心契约。 + */ + function isRealPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + + const dataSource = readDataSource(record); + // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。 + if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false; + + if (demoSourceSet.has(readMetadataSource(record))) return false; + + return true; + } + + /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */ + function isDemoPracticeRecord(record) { + if (!record || typeof record !== 'object') return false; + return !isRealPracticeRecord(record); + } + + function filterRealPracticeRecords(records) { + return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord); + } + + // ----------------------------------------------------------------------- + // 引导预览白名单(仅影响渲染,永不影响统计与成就) + // + // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records, + // 再等待它在练习记录列表里出现(js/components/onboardingTour.js + // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。 + // + // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'), + // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户 + // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。 + // + // 关键设计:例外只存在于视图层白名单,投影器根本读不到它—— + // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。 + // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。 + // ----------------------------------------------------------------------- + const previewRecordIds = new Set(); + + function normalizeId(value) { + if (value === undefined || value === null) return ''; + return String(value).trim(); + } + + /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */ + function allowPreviewRecordId(recordId) { + const id = normalizeId(recordId); + if (id) previewRecordIds.add(id); + return id !== ''; + } + + /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */ + function clearPreviewRecordId(recordId) { + if (recordId === undefined) { + previewRecordIds.clear(); + return true; + } + return previewRecordIds.delete(normalizeId(recordId)); + } + + function isPreviewRecord(record) { + if (!previewRecordIds.size || !record || typeof record !== 'object') return false; + if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false; + const id = normalizeId(record.id || record.recordId); + return Boolean(id && previewRecordIds.has(id)); + } + + /** + * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。 + * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。 + */ + function filterRecordsForHistoryView(records) { + return (Array.isArray(records) ? records : []) + .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record)); + } + + const api = Object.freeze({ + __stable: true, + REAL_DATA_SOURCES, + DEMO_SOURCE_MARKERS, + ONBOARDING_PREVIEW_MARKERS, + isRealPracticeRecord, + isDemoPracticeRecord, + filterRealPracticeRecords, + allowPreviewRecordId, + clearPreviewRecordId, + isPreviewRecord, + filterRecordsForHistoryView + }); + + global.PracticeRecordSource = api; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataCatalog.js ===== */ +(function installDataCatalog(global) { + 'use strict'; + + const V2_SCHEMA_VERSION = 2; + + function clone(value) { + if (value === undefined) return undefined; + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* fall through */ } + } + return JSON.parse(JSON.stringify(value)); + } + + function objectDefault() { return {}; } + function arrayDefault() { return []; } + function nullableDefault() { return null; } + function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; } + function normalizeObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {}; + } + function normalizeNullableString(value) { + return value === null || value === undefined || value === '' ? null : String(value); + } + function isArray(value) { return Array.isArray(value); } + function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } + function isNullableString(value) { return value === null || typeof value === 'string'; } + + const CATALOG_OWNERS = new Set([ + 'settings', 'library', 'recovery', 'backups', 'vocab', + 'preferences', 'goals', 'achievements', 'system', 'practice' + ]); + const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']); + const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']); + + function isNonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); + } + + function ownerFromKey(logicalKey) { + const dot = String(logicalKey || '').indexOf('.'); + return dot > 0 ? logicalKey.slice(0, dot) : ''; + } + + function freezeEntry(entry) { + const logicalKey = String(entry.logicalKey || ''); + const owner = ownerFromKey(logicalKey); + const next = Object.assign({}, entry, { + logicalKey, + owner, + schemaVersion: V2_SCHEMA_VERSION, + export: entry.export === true, + import: entry.import || 'ignore' + }); + return Object.freeze(next); + } + + // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations), + // not as document keys. import merge identity is resolved in AppData, not here. + const definitions = [ + { + logicalKey: 'settings.values', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.configurations', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'library.importedIndexes', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'library.activeConfigurationId', classification: 'authoritative', + defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString, + export: true, import: 'replace' + }, + { + logicalKey: 'recovery.activeSessions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.drafts', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.interrupted', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'recovery.windowSession', classification: 'session', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.entries', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'merge-by-id' + }, + { + logicalKey: 'backups.settings', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'backups.exportHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'backups.importHistory', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: false, import: 'ignore' + }, + { + logicalKey: 'vocab.words', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'vocab.userConfig', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'vocab.lists', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'preferences.values', classification: 'preference', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'goals.items', classification: 'authoritative', + defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray, + export: true, import: 'merge-by-id' + }, + { + logicalKey: 'achievements.manual', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'achievements.progress', classification: 'authoritative', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: true, import: 'patch' + }, + { + logicalKey: 'system.migrations', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + }, + { + logicalKey: 'system.operationJournal', classification: 'system', + defaultValue: objectDefault, normalize: normalizeObject, validate: isObject, + export: false, import: 'ignore' + } + ].map(freezeEntry); + + function validateCatalog(entries) { + if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry'); + const logicalKeys = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('DataCatalog entry must be an object'); + } + if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) { + throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`); + } + logicalKeys.add(entry.logicalKey); + } + for (const entry of entries) { + if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) { + throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`); + } + if (!CATALOG_CLASSIFICATIONS.has(entry.classification) + || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION + || typeof entry.defaultValue !== 'function' + || typeof entry.normalize !== 'function' + || typeof entry.validate !== 'function' + || typeof entry.export !== 'boolean' + || !IMPORT_POLICIES.has(entry.import)) { + throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`); + } + try { + const defaultValue = entry.defaultValue(); + if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) { + throw new Error('invalid default'); + } + } catch (_) { + throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`); + } + } + return true; + } + + validateCatalog(definitions); + const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry])); + const DataCatalog = Object.freeze({ + version: V2_SCHEMA_VERSION, + list() { return definitions.slice(); }, + get(logicalKey) { + const entry = byKey.get(String(logicalKey || '')); + if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`); + return entry; + }, + has(logicalKey) { return byKey.has(String(logicalKey || '')); }, + validate: validateCatalog, + clone + }); + + Object.defineProperty(global, '__AppDataV2Catalog', { + value: DataCatalog, + enumerable: false, + configurable: true, + writable: false + }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/dataKernel.js ===== */ +(function installDataKernel(global) { + 'use strict'; + + if (global.AppData) return; + + const catalog = global.__AppDataV2Catalog; + if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel'); + + const DATABASE_NAME = 'IELTSAtlasDataV2'; + // Version 2 uses a new schema, but initialization must still import the durable + // ExamSystemDB data owned by releases which predate AppData v2. + const DATABASE_VERSION = 2; + const DOCUMENT_STORE = 'documents'; + const SYSTEM_STORE = 'system'; + const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES)); + const OPERATION_JOURNAL_WINDOW = 500; + const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`; + const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000; + const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000; + const MAX_TIMER_DELAY_MS = 2147483647; + const LEGACY_DATABASE_NAME = 'ExamSystemDB'; + const LEGACY_STORE_NAME = 'keyValueStore'; + const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup'; + const LEGACY_EXTERNAL_STORE_NAME = 'handles'; + const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory'; + const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json'; + const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([ + 'practice_records', + 'vocab_user_config', + 'user_achievements' + ]); + + function clone(value) { return catalog.clone(value); } + function nowIso() { return new Date().toISOString(); } + function randomId(prefix) { + const random = global.crypto && typeof global.crypto.randomUUID === 'function' + ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + return `${prefix || 'op'}_${random}`; + } + + class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'AppDataError'; + this.code = code; + this.committed = false; + this.details = details; + } + } + function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); } + function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); } + + function normalizeTimeoutMs(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback; + } + function scheduleTimeout(handler, delayMs) { + if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable'); + const handle = global.setTimeout.call(global, handler, delayMs); + if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle'); + return handle; + } + function cancelTimeout(handle) { + if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') { + try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ } + } + } + function withDeadline(handle, timeoutMs, description, resolve, reject) { + let settled = false; + let timer = null; + const settle = (callback, value) => { + if (settled) return; + settled = true; + cancelTimeout(timer); + callback(value); + }; + const expire = (error) => { + if (settled) return; + settled = true; + try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ } + reject(error); + }; + try { + timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, { + operation: description, timeoutMs, reason: 'timeout' + })), timeoutMs); + } catch (error) { + expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, { + operation: description, reason: 'watchdog-unavailable', cause: error && error.message + })); + } + return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } }; + } + + function canonicalizeJson(value, path = '$', ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path }); + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') { + throw validation(`Non-JSON value at ${path}`, { path, type: typeof value }); + } + if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path }); + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path }); + if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) { + throw validation(`Symbol-keyed property at ${path}`, { path }); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item, index) => { + if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path }); + return canonicalizeJson(item, `${path}[${index}]`, ancestors); + }); + } + const result = {}; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path }); + result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors); + } + return result; + } finally { ancestors.delete(value); } + } + function stableStringifyCanonical(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`; + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`; + } + function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); } + function checksum(value) { + const input = stableStringify(value); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + function legacyTimestamp(value) { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity; + if (Number.isFinite(Number(value))) return Number(value); + const parsed = Date.parse(value == null ? '' : String(value)); + return Number.isFinite(parsed) ? parsed : -Infinity; + } + function parseLegacyCandidate(value, outerTimestamp) { + let parsed = value; + let timestamp = legacyTimestamp(outerTimestamp); + const hasOuterTimestamp = timestamp !== -Infinity; + for (let depth = 0; depth < 3; depth += 1) { + if (typeof parsed === 'string') { + try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; } + } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') + && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) { + const innerTimestamp = legacyTimestamp(parsed.timestamp); + if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp; + parsed = parsed.data; + } else break; + } + return { value: clone(parsed), timestamp }; + } + function parseLegacyValue(value) { + const candidate = parseLegacyCandidate(value); + return candidate ? candidate.value : clone(value); + } + async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) { + const values = {}; + const candidates = {}; + let readComplete = true; + const consider = (alias, rawValue, timestamp, sourceRank) => { + const candidate = parseLegacyCandidate(rawValue, timestamp); + if (!candidate) return; + const previous = candidates[alias]; + if (!previous || candidate.timestamp > previous.timestamp + || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) { + candidates[alias] = Object.assign(candidate, { sourceRank }); + } + }; + if (indexedDBApi && typeof indexedDBApi.open === 'function') { + await new Promise((resolve) => { + let request; + let createdEmptyDatabase = false; + try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; } + request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); }; + request.onupgradeneeded = () => { + createdEmptyDatabase = true; + try { request.transaction.abort(); } catch (_) {} + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; } + const tx = db.transaction(LEGACY_STORE_NAME, 'readonly'); + const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys(); + const rows = tx.objectStore(LEGACY_STORE_NAME).getAll(); + tx.oncomplete = () => { + (keys.result || []).forEach((key, index) => { + const row = (rows.result || [])[index]; + // v1's keyValueStore persisted { key, value, timestamp } rows. + const validRow = row && typeof row === 'object' + && Object.prototype.hasOwnProperty.call(row, 'key') + && String(row.key) === String(key) + && Object.prototype.hasOwnProperty.call(row, 'value'); + if (!validRow) { + readComplete = false; + return; + } + consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0); + }); + db.close(); resolve(); + }; + tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); }; + }; + }); + } + for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) { + if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue; + for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) { + const key = fallbackStorage.key(index); + if (!key) continue; + const alias = key.startsWith('exam_system_') + ? key.slice('exam_system_'.length) + : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null); + if (!alias) continue; + try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ } + } + } + for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value; + Object.defineProperty(values, '__legacyReadComplete', { + value: readComplete, + enumerable: false, + configurable: false, + writable: false + }); + return values; + } + async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) { + if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null; + const directoryHandle = await new Promise((resolve) => { + let request; + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value || null); + }; + try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; } + request.onerror = () => finish(null); + request.onupgradeneeded = () => { + try { request.transaction.abort(); } catch (_) {} + finish(null); + }; + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) { + db.close(); finish(null); return; + } + const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly') + .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY); + get.onerror = () => { db.close(); finish(null); }; + get.onsuccess = () => { db.close(); finish(get.result); }; + }; + }); + if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null; + if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null; + const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false }); + const parsed = JSON.parse(await (await fileHandle.getFile()).text()); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined + ? parsed.data : parsed; + return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null; + } + function lookupEntry(logicalKey) { + if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey }); + return catalog.get(logicalKey); + } + function storeFor(logicalKey) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey }); + return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE; + } + function makeEnvelope(entry, data, options = {}) { + const state = options.state === 'cleared' ? 'cleared' : 'present'; + if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`); + let normalized = null; + if (state === 'present') { + try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) { + throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message }); + } + normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`); + if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey }); + } + const revision = options.revision === undefined ? 1 : Number(options.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`); + const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')), + updatedAt: options.updatedAt || nowIso(), state, data: normalized }; + payload.checksum = checksum(payload.data); + return Object.freeze(payload); + } + function validateEnvelope(entry, envelope) { + try { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope) + || Number(envelope.schemaVersion) !== Number(entry.schemaVersion) + || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1 + || typeof envelope.operationId !== 'string' || !envelope.operationId + || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt + || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false; + const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`); + return (envelope.state !== 'cleared' || data === null) + && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data); + } catch (_) { return false; } + } + function operationId(value) { + if (value === undefined || value === null || value === '') return randomId('mutation'); + if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string'); + return value; + } + function expectedRevision(value, label) { + if (value === undefined || value === null) return null; + const revision = Number(value); + if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`); + return revision; + } + function compactJournal(journal) { + const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence)); + for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]]; + } + function readJournal(row) { + const envelope = row && row.envelope; + return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data) + ? clone(envelope.data) : {}; + } + function journalResult(journal, spec) { + const existing = journal[spec.operationId]; + if (!existing) return null; + if (existing.fingerprint !== spec.fingerprint || !existing.receipt) { + throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId }); + } + return clone(existing.receipt); + } + function writeJournal(journal, spec, receipt) { + const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1; + journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() }; + compactJournal(journal); + return journal; + } + function putJournal(tx, currentRow, journal, spec, receipt) { + const current = currentRow && currentRow.envelope; + const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), { + revision: current ? Number(current.revision) + 1 : 1, + operationId: spec.operationId, + normalized: true + }); + tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) }); + } + + class IndexedDBDriver { + constructor(indexedDBApi, options) { + this.indexedDB = indexedDBApi; + this.db = null; + this.mutationTimeoutMs = options.mutationTimeoutMs; + this.requestTimeoutMs = options.requestTimeoutMs; + } + async initialize() { + if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable'); + this.db = await new Promise((resolve, reject) => { + const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + let abandoned = false; + let settle; + request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); }; + request.onupgradeneeded = (event) => { + const db = request.result; + if (event.oldVersion < 2) { + for (const name of ['authoritative', 'derived']) { + if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name); + } + } + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' }); + } + }; + settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject); + request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB')); + request.onblocked = () => { + abandoned = true; + settle.reject(new Error('IndexedDB upgrade blocked')); + }; + }); + this.db.onversionchange = () => this.close(); + return this; + } + close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} } + _open() { if (!this.db) throw new Error('IndexedDB connection closed'); } + _transaction(stores, mode, description, work, mutation = false) { + this._open(); + return new Promise((resolve, reject) => { + let failure = null; + let value; + let tx; + try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; } + const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject); + tx.oncomplete = () => settle.resolve(clone(value)); + tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); }; + tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`)); + const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} }; + try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); } + }); + } + readEnvelope(logicalKey) { + const store = storeFor(logicalKey); + return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(logicalKey); + request.onsuccess = () => done(request.result ? request.result.envelope : null); + request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`)); + }); + } + readEntity(store, recordId) { + return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => { + const request = tx.objectStore(store).get(recordId); + request.onsuccess = () => done(request.result || null); + request.onerror = () => fail(request.error || new Error('Entity read failed')); + }); + } + readPracticeSnapshot(recordIds = null, options = {}) { + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES.slice(); + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean)); + return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => { + const result = Object.fromEntries(stores.map((store) => [store, []])); + let remaining = stores.length; + const finishStore = (store, rows) => { + result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || ''))); + remaining -= 1; + if (!remaining) done(result); + }; + for (const store of stores) { + const objectStore = tx.objectStore(store); + const request = requested && requested.size === 1 + ? objectStore.get(Array.from(requested)[0]) + : objectStore.getAll(); + request.onsuccess = () => { + const rows = requested && requested.size === 1 + ? (request.result ? [request.result] : []) + : request.result; + finishStore(store, rows); + }; + request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`)); + } + }); + } + listEntities(store) { + return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => { + const request = tx.objectStore(store).getAll(); + request.onsuccess = () => done(request.result || []); + request.onerror = () => fail(request.error || new Error('Entity list failed')); + }); + } + atomic(spec) { + return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => { + const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal'); + journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed')); + journalRequest.onsuccess = () => { + try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); } + }; + }, true); + } + exportSnapshot(envelopeKeys) { + return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => { + const result = { envelopes: {}, entities: {} }; + let remaining = STORE_NAMES.length; + for (const store of STORE_NAMES) { + const request = tx.objectStore(store).getAll(); + request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`)); + request.onsuccess = () => { + if (store === DOCUMENT_STORE || store === SYSTEM_STORE) { + for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope; + } else result.entities[store] = request.result || []; + remaining -= 1; + if (!remaining) done(result); + }; + } + }); + } + } + + function entityStore(store) { + const value = String(store || ''); + if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value }); + return value; + } + function validateEntityRow(store, row) { + if (!row || typeof row !== 'object' || Array.isArray(row) + || typeof row.recordId !== 'string' || !row.recordId + || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1 + || typeof row.operationId !== 'string' || !row.operationId + || typeof row.updatedAt !== 'string' || !row.updatedAt) { + throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null }); + } + const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`); + if (row.checksum !== checksum(data)) { + throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId }); + } + return row; + } + function normalizeEntityOperation(operation, index) { + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`); + const type = String(operation.type || ''); + const store = entityStore(operation.store); + if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`); + const recordId = type === 'clear' ? null : String(operation.recordId || ''); + if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`); + const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null; + return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) }; + } + function receiptFor(operationIdValue, revisions, warnings, pending) { + const receipt = { committed: true, revisions, operationId: operationIdValue, + derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() }; + const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]]; + return receipt; + } + + class DataKernel { + constructor(options = {}) { + this.driver = null; + this.backend = null; + this.state = 'created'; + this.failure = null; + this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB; + this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS); + this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS); + this.committedListeners = new Set(); + this.commitChannel = null; + this.instanceId = randomId('kernel'); + this.ready = null; + } + _initializeCommitChannel() { + if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return; + try { + const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME); + channel.onmessage = (message) => { + const data = message && message.data; + if (!data || data.sourceInstanceId === this.instanceId + || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return; + this._dispatchCommitted({ + operationId: data.operationId, + targets: clone(data.targets), + receipt: data.receipt ? clone(data.receipt) : null, + remote: true + }); + }; + this.commitChannel = channel; + } catch (_) { + this.commitChannel = null; + } + } + _closeCommitChannel() { + const channel = this.commitChannel; + this.commitChannel = null; + try { if (channel) channel.close(); } catch (_) {} + } + initialize() { + if (this.ready) return this.ready; + this.state = 'initializing'; + this.ready = new IndexedDBDriver(this.indexedDB, { + mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs + }).initialize().then((driver) => { + this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this; + }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null; + throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); }); + return this.ready; + } + close() { + if (this.driver) this.driver.close(); + this.driver = null; this.backend = null; + this._closeCommitChannel(); + if (this.state !== 'failed') this.state = 'closed'; + } + _assertReady() { + if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message }); + if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized'); + } + _latch(error) { + if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message }); + this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel(); + return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message }); + } + onCommitted(listener) { + if (typeof listener !== 'function') throw validation('Committed listener must be a function'); + this.committedListeners.add(listener); return () => this.committedListeners.delete(listener); + } + _dispatchCommitted(event) { + if (!event || !this.committedListeners.size) return; + const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback); + schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} })); + } + _notifyCommitted(targets, receipt) { + if (!targets.length) return; + const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false }; + this._dispatchCommitted(event); + if (this.commitChannel) { + try { + this.commitChannel.postMessage({ + sourceInstanceId: this.instanceId, + operationId: event.operationId, + targets: event.targets, + receipt: event.receipt + }); + } catch (_) { /* cross-realm notification is best effort */ } + } + } + async getEnvelope(logicalKey) { + this._assertReady(); const entry = lookupEntry(logicalKey); + try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async read(logicalKey, options = {}) { + const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey); + const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data; + return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data); + } + _documentSpec(changes, options) { + if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes'); + const opId = operationId(options.operationId); const seen = new Set(); + const prepared = changes.map((change, index) => { + if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`); + const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey); + if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel'); + if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey); + const state = change.state === 'cleared' ? 'cleared' : 'present'; + if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`); + if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`); + let data = null; + if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); } + return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) }; + }); + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings }); + return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) }; + } + async mutate(changes, options = {}) { + this._assertReady(); const spec = this._documentSpec(changes, options); + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) })); + let remaining = reads.length; + const finish = () => { + const revisions = {}; + for (const item of reads) { + const current = item.request.result ? item.request.result.envelope : null; + if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey }); + const revision = current ? Number(current.revision) : 0; + if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision }); + const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true }); + tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision; + } + const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []); + putJournal(tx, journalRow, journal, spec, receipt); + done(receipt); + }; + if (!remaining) { finish(); return; } + for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification })); + this._notifyCommitted(targets, receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); } + async readEntity(store, recordId, options = {}) { + this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId'); + try { + const row = await this.driver.readEntity(store, id); + if (!row) return null; + validateEntityRow(store, row); + return options.withMeta ? clone(row) : clone(row.data); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async readPracticeSnapshot(recordIds = null, options = {}) { + this._assertReady(); + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + try { + const snapshot = await this.driver.readPracticeSnapshot(ids, options); + const result = {}; + const stores = Array.isArray(options.stores) && options.stores.length + ? Array.from(new Set(options.stores.map((store) => entityStore(store)))) + : ENTITY_STORES; + for (const store of stores) { + const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : []) + .filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + result[store] = options.withMeta + ? clone(validRows) + : validRows.map((row) => clone(row.data)); + } + return result; + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async listEntities(store, options = {}) { + this._assertReady(); store = entityStore(store); + if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId'); + try { + const rows = await this.driver.listEntities(store); + const validRows = rows.filter((row) => { + try { validateEntityRow(store, row); return true; } + catch (error) { + if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false; + throw error; + } + }); + return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data)); + } + catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async mutateEntities(operations, options = {}) { + this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations'); + const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set(); + for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); } + for (const store of ENTITY_STORES) { + const scoped = items.filter((item) => item.store === store); + if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) { + throw validation(`Entity clear cannot be combined with other operations for ${store}`); + } + } + const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings'); + if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings'); + const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length; + const finish = () => { const revisions = {}; + for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0; + if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`); + const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; } + } + for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; } + const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); }; + if (!remaining) { try { finish(); } catch (error) { fail(error); } return; } + for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; } + } })); + this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt; + } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + async exportSnapshot(options = {}) { + this._assertReady(); + try { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null; + const shouldExport = (logicalKey) => { + if (!catalog.has(logicalKey)) return false; + const entry = lookupEntry(logicalKey); + if (selected && !selected.has(logicalKey)) return false; + if (entry.export === true) return true; + return options.includeSystem === true && entry.classification === 'system'; + }; + const data = await this.driver.exportSnapshot(shouldExport); + // Full/partial snapshots must be dense for their declared catalog + // range. An absent physical row means the catalog default, not an + // instruction that future importers should guess about. + for (const entry of catalog.list()) { + if (!shouldExport(entry.logicalKey) + || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue; + data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, { + state: 'cleared', + operationId: 'snapshot-default' + }); + } + if (Array.isArray(options.entityStores)) { + const selectedStores = new Set(options.entityStores.map(entityStore)); + for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store]; + } + const payload = { envelopes: data.envelopes, entities: data.entities }; + return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) }; + } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); } + } + async installSnapshot(snapshot, options = {}) { + this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} }; + const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities'); + if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid'); + if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch'); + const changes = []; + for (const [logicalKey, envelope] of Object.entries(envelopes)) { + const entry = lookupEntry(logicalKey); + if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue; + if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`); + changes.push({ logicalKey, entry, envelope }); + } + const entityRows = {}; + for (const store of ENTITY_STORES) { + if (!Object.prototype.hasOwnProperty.call(entities, store)) continue; + const rows = entities[store]; + if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`); + const ids = new Set(); + entityRows[store] = rows.map((row) => { + if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`); + const recordId = String(row.recordId); + if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`); + ids.add(recordId); + const data = canonicalizeJson(row.data); + if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`); + const revision = row.revision === undefined ? 1 : Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`); + return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) }; + }); + } + if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data'); + const resetJournal = options.resetJournal === true; + const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object' + ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken') + : null; + const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() }; + try { + const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => { + const replay = journalResult(journal, spec); if (replay) { done(replay); return; } + const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {}; + const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {}; + const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({ + kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey) + })).concat(Object.entries(entityChecks).map(([store, expected]) => ({ + kind: 'entities', store, expected, request: tx.objectStore(store).getAll() + }))); + const finish = () => { + for (const read of reads) { + if (read.kind === 'document') { + const current = read.request.result ? read.request.result.envelope : null; + const actualRevision = current ? Number(current.revision) : 0; + const expectedRevision = Number(read.expected) || 0; + if (actualRevision !== expectedRevision) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision }); + } + } else { + const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0])); + const expected = read.expected && typeof read.expected === 'object' ? read.expected : {}; + const ids = new Set(Object.keys(actual).concat(Object.keys(expected))); + for (const recordId of ids) { + const current = actual[recordId] || 0; + const wanted = Number(expected[recordId]) || 0; + if (current !== wanted) { + throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId }); + } + } + } + } + const revisions = {}; + for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); } + for (const [store, rows] of Object.entries(entityRows)) { + tx.objectStore(store).clear(); + for (const row of rows) tx.objectStore(store).put(row); + } + const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt); + }; + if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; } + let remaining = reads.length; + for (const read of reads) { + read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed')); + read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; + } + } })); + const targets = changes + .filter((item) => item.entry.owner !== 'backups') + .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification })) + .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' }))); + this._notifyCommitted(targets, receipt); + return receipt; + } + catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); } + } + status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); } + } + + Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false }); +})(typeof window !== 'undefined' ? window : globalThis); + + +/* ===== js/data/v2/appData.js ===== */ +(function installAppData(global) { + 'use strict'; + + const internals = global.__AppDataV2Internals; + if (!internals || typeof internals.DataKernel !== 'function') { + throw new Error('AppData v2 requires DataKernel'); + } + const { + DataKernel, + AppDataError, + catalog, + clone, + randomId, + nowIso, + checksum + } = internals; + const kernel = new DataKernel(); + const importPlans = new Map(); + const RECOVERY_KEYS = Object.freeze({ + activeSession: 'recovery.activeSessions', + draft: 'recovery.drafts', + interrupted: 'recovery.interrupted', + rejectedCompletion: 'recovery.rejectedCompletions' + }); + const PREFERENCE_FIELDS = Object.freeze({ + theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode', + resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay', + threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget', + consent: 'consent', logConfig: 'logConfig' + }); + const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); + + function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function asArray(value) { return Array.isArray(value) ? value : []; } + function idOf(value, fields) { + for (const field of fields) { + if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]); + } + return ''; + } + function importedLibraryId(value, options = {}) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id && options.nullable) return null; + if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required'); + if (/^exam_index(?:_|$)/.test(id)) { + throw new AppDataError('VALIDATION', 'Unsupported library configuration id'); + } + return id; + } + function assertObject(value, message) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function assertArray(value, message) { + if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message); + } + function jsonValue(value, label = 'value') { + try { + const serialized = JSON.stringify(value, (_key, current) => { + if (typeof current === 'bigint') return String(current); + if (typeof current === 'number' && !Number.isFinite(current)) return null; + return current; + }); + if (serialized === undefined) return null; + return JSON.parse(serialized); + } catch (error) { + throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message }); + } + } + function operationId(command, prefix, semanticPayload = command) { + const id = command && command.operationId ? String(command.operationId) : randomId(prefix); + jsonValue(semanticPayload, `${prefix} payload`); + return id; + } + function mutationOptions(command, prefix, semanticPayload, extra = {}) { + const source = asObject(command); + const payload = jsonValue(semanticPayload, `${prefix} payload`); + const intent = { command: prefix, payload }; + if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) { + intent.expectedRevision = source.expectedRevision; + } + return Object.assign({}, extra, { + operationId: operationId(source, prefix, payload), + intent + }); + } + function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) { + return mutationOptions(asObject(options), prefix, semanticPayload, extra); + } + function deterministicEntityId(prefix, operation) { + return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`; + } + function normalizeAccuracyRatio(value, label = 'accuracy') { + if (value === undefined || value === null || value === '') return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) { + throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`); + } + return numeric > 1 ? numeric / 100 : numeric; + } + function defaultStats() { + return { + totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0, + reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 }, + lastUpdated: nowIso() + }; + } + + function nonNegativeScalar(value) { + if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; + } + + function firstNonNegativeScalar(...values) { + for (const value of values) { + const numeric = nonNegativeScalar(value); + if (numeric !== null) return numeric; + } + return null; + } + + function normalizeAnswerQuestionId(value, index = 0) { + const text = value === undefined || value === null ? '' : String(value).trim(); + return text || `q${index + 1}`; + } + + function normalizeAnswerValue(value) { + if (value === undefined || value === null) return ''; + if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); + if (value && typeof value === 'object') { + if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); + if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); + if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); + return jsonValue(value, 'practice answer'); + } + return typeof value === 'string' ? value : String(value); + } + + function normalizeAnswerMap(value) { + const normalized = {}; + if (Array.isArray(value)) { + value.forEach((entry, index) => { + if (entry === undefined || entry === null) return; + const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; + const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); + normalized[questionId] = normalizeAnswerValue( + hasOwn(item, 'answer') ? item.answer + : hasOwn(item, 'userAnswer') ? item.userAnswer + : hasOwn(item, 'value') ? item.value + : entry + ); + }); + return normalized; + } + if (!value || typeof value !== 'object') return normalized; + Object.entries(value).forEach(([questionId, answer], index) => { + if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; + normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); + }); + return normalized; + } + + function mergeAnswerMaps(...sources) { + const merged = {}; + for (const source of sources) { + const normalized = normalizeAnswerMap(source); + for (const [questionId, answer] of Object.entries(normalized)) { + if (!hasOwn(merged, questionId)) merged[questionId] = answer; + } + } + return merged; + } + + function canonicalizeAnswerSource(record) { + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const rawRealData = asObject(rawData.realData); + record.answers = mergeAnswerMaps( + record.answers, + record.answerMap, + record.answerList, + realData.answers, + realData.answerMap, + rawData.answers, + rawData.answerMap, + rawRealData.answers + ); + // These names remain accepted only at the compatibility boundary. The + // canonical detail layer owns one user-answer source: `answers`. + delete record.answerMap; + delete record.answerList; + } + + function normalizePracticeScoreFields(record) { + const scoreInfo = asObject(record.scoreInfo); + const realScoreInfo = asObject(asObject(record.realData).scoreInfo); + const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); + const overloadedCorrectAnswers = record.correctAnswers; + const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; + if (isAnswerMap) { + const existingMap = record.correctAnswerMap; + const overloadedObject = asObject(overloadedCorrectAnswers); + const existingObject = asObject(existingMap); + if (Object.keys(overloadedObject).length) { + record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); + } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { + record.correctAnswerMap = clone(overloadedCorrectAnswers); + } + } + + let correctAnswers = firstNonNegativeScalar( + overloadedCorrectAnswers, + record.correctAnswersCount, + record.correctCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct, + rawScoreInfo.correctAnswers, + rawScoreInfo.correct + ); + if (correctAnswers === null) { + const comparisons = asArray(record.answerComparison).length + ? asArray(record.answerComparison) + : asArray(asObject(record.realData).answerComparison); + if (comparisons.length) { + correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; + } + } + if (correctAnswers !== null) record.correctAnswers = correctAnswers; + else if (isAnswerMap) record.correctAnswers = 0; + + const totalQuestions = firstNonNegativeScalar( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total, + rawScoreInfo.totalQuestions, + rawScoreInfo.total + ); + if (totalQuestions !== null) record.totalQuestions = totalQuestions; + } + + function canonicalizeRecord(input) { + assertObject(input, 'practice record must be an object'); + const record = jsonValue(input, 'practice record'); + record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record'); + record.sessionId = idOf(record, ['sessionId']) || record.id; + record.timestamp = record.timestamp || record.completedAt || record.date || nowIso(); + record.completedAt = record.completedAt || record.timestamp; + record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice'; + record.metadata = asObject(record.metadata); + if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; + if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; + canonicalizeAnswerSource(record); + normalizePracticeScoreFields(record); + for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { + if (record[field] === undefined || record[field] === null || record[field] === '') continue; + const numeric = Number(record[field]); + if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`); + record[field] = numeric; + } + if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy'); + return jsonValue(record, 'canonical practice record'); + } + + function deriveQuestionTypeErrorCounts(source) { + const record = asObject(source); + const realData = asObject(record.realData); + const rawData = asObject(record.rawData); + const counts = {}; + const addCount = (type, value) => { + const key = String(type || 'other').trim() || 'other'; + const amount = Math.max(0, Number(value) || 0); + if (amount > 0) counts[key] = (counts[key] || 0) + amount; + }; + const performanceSources = [ + record.questionTypePerformance, + realData.questionTypePerformance, + rawData.questionTypePerformance + ]; + let hasPerformanceData = false; + for (const performanceMap of performanceSources) { + if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; + let sourceHasPerformanceData = false; + for (const [type, value] of Object.entries(performanceMap)) { + const performance = asObject(value); + const total = Number(performance.total ?? performance.totalQuestions); + const correct = Number(performance.correct ?? performance.correctAnswers); + if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; + hasPerformanceData = true; + sourceHasPerformanceData = true; + addCount(type, total - correct); + } + if (sourceHasPerformanceData) break; + } + if (hasPerformanceData) return counts; + + const questionTypeMap = Object.assign( + {}, + asObject(rawData.questionTypeMap), + asObject(realData.questionTypeMap), + asObject(record.questionTypeMap) + ); + const normalizedTypeMap = {}; + for (const [questionId, type] of Object.entries(questionTypeMap)) { + normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; + } + const detailSources = [ + record.answerDetails, + asObject(record.scoreInfo).details, + realData.answerDetails, + asObject(realData.scoreInfo).details, + rawData.answerDetails, + asObject(rawData.scoreInfo).details + ]; + const seenQuestions = new Set(); + for (const details of detailSources) { + if (!details || typeof details !== 'object' || Array.isArray(details)) continue; + for (const [questionId, value] of Object.entries(details)) { + const detail = asObject(value); + const normalizedId = String(questionId).trim().toLowerCase(); + if (!normalizedId || seenQuestions.has(normalizedId)) continue; + let isWrong = detail.isCorrect === false || detail.correct === false; + if (detail.isCorrect === true || detail.correct === true) isWrong = false; + else if (!isWrong) { + const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); + const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); + isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); + } + if (!isWrong) continue; + seenQuestions.add(normalizedId); + addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); + } + } + return counts; + } + + function lightSuiteEntry(source, fallbackType = null) { + const entry = asObject(source); + const scoreInfo = asObject(entry.scoreInfo); + const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); + const metadata = asObject(entry.metadata); + const totalQuestions = firstNonNegativeScalar( + entry.totalQuestions, + scoreInfo.totalQuestions, + scoreInfo.total, + realScoreInfo.totalQuestions, + realScoreInfo.total + ) ?? 0; + const correctAnswers = firstNonNegativeScalar( + entry.correctAnswers, + scoreInfo.correctAnswers, + scoreInfo.correct, + realScoreInfo.correctAnswers, + realScoreInfo.correct + ) ?? 0; + const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'suite entry accuracy' + ) || 0; + const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0; + return jsonValue({ + id: entry.id || null, + sessionId: entry.sessionId || null, + examId: entry.examId || metadata.examId || null, + title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', + type: entry.type || metadata.type || metadata.examType || fallbackType || null, + date: entry.date || entry.completedAt || entry.timestamp || null, + duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage, + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + }, 'suite entry light projection'); + } + + function lightFromCanonical(source) { + const scoreInfo = asObject(source.scoreInfo); + const realScoreInfo = asObject(asObject(source.realData).scoreInfo); + const metadata = asObject(source.metadata); + const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field); + const dataSource = hasOwn(source, 'dataSource') + ? source.dataSource + : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined); + const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0; + const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0; + const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; + const accuracy = normalizeAccuracyRatio( + explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), + 'practice light accuracy' + ) || 0; + return jsonValue({ + id: source.id, + sessionId: source.sessionId, + examId: source.examId || source.metadata.examId || null, + title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '', + type: source.type, + mode: source.mode || source.practiceMode || null, + timestamp: source.timestamp, + completedAt: source.completedAt, + date: source.date || source.completedAt || source.timestamp || null, + startTime: source.startTime || null, + endTime: source.endTime || null, + duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, + totalQuestions, + correctAnswers, + accuracy, + percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, + score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` + // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 + // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 + dataSource, + // Summaries are list indexes. Keep only the metadata needed to filter, show a + // source label, or locate the originating library; details stay in their entity. + metadata: Object.fromEntries([ + // `source` must stay: PracticeRecordSource uses metadata.source demo markers + // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full. + 'examId', 'examTitle', 'title', 'type', 'category', 'frequency', + 'dataSource', 'source', 'libraryConfigurationId' + ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), + suite: source.suite == null ? null : clone(asObject(source.suite)), + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), + questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + }, 'practice light projection'); + } + + function projectLight(record) { + if (!record) return null; + return lightFromCanonical(canonicalizeRecord(record)); + } + + function firstNonEmpty(...values) { + let first; + for (const value of values) { + if (value === undefined || value === null) continue; + if (first === undefined) first = value; + if (Array.isArray(value) && value.length) return clone(value); + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value); + if (typeof value !== 'object') return clone(value); + } + return first === undefined ? {} : clone(first); + } + + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); + + function withoutRawData(value) { + if (Array.isArray(value)) return value.map(withoutRawData); + if (!value || typeof value !== 'object') return clone(value); + const clean = {}; + for (const [key, item] of Object.entries(value)) { + if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item); + } + return clean; + } + + function splitPracticeRecord(input) { + const source = canonicalizeRecord(input); + const summary = lightFromCanonical(source); + const detail = { recordId: source.id }; + const annotations = { recordId: source.id }; + for (const [key, value] of Object.entries(source)) { + if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); + else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { + const next = Object.assign({}, asObject(entry)); + canonicalizeAnswerSource(next); + const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); + for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); + } + const annotation = {}; + for (const annotationKey of ANNOTATION_FIELDS) { + if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; } + if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey]; + if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey]; + } + delete next.realData; delete next.rawData; + if (Object.keys(annotation).length) { + if (!annotations.suiteEntries) annotations.suiteEntries = {}; + annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation; + } + return withoutRawData(next); + }); + else detail[key] = withoutRawData(value); + } + // Accept the old mirror only as an input normalization boundary; it is never persisted. + const realData = asObject(source.realData); const rawData = asObject(source.rawData); + for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); + } + for (const key of ANNOTATION_FIELDS) { + if (hasOwn(annotations, key)) continue; + if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]); + else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]); + } + return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') }; + } + + function joinPracticeRecord(summary, detail, annotations, projection = 'full') { + if (!summary) return null; + const mode = String(projection || 'full').toLowerCase(); + const light = clone(summary); + if (mode === 'light' || mode === 'summary') return light; + const joined = Object.assign({}, light, clone(asObject(detail))); + delete joined.recordId; + if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection'); + const annotationData = asObject(annotations); + for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value); + if (Array.isArray(joined.suiteEntries)) { + const suiteAnnotations = asObject(annotationData.suiteEntries); + joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {}))); + } + return jsonValue(joined, 'practice full projection'); + } + + function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); } + + // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。 + // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定, + // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败, + // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。 + const practiceRecordSource = global.PracticeRecordSource; + if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') { + throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)'); + } + const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord; + + function computeStats(records) { + const stats = defaultStats(); + for (const record of asArray(records).filter(isRealPracticeRecord)) { + const summary = projectLight(record); + const type = String(summary.type || '').toLowerCase(); + const target = type.includes('listen') ? stats.listening : stats.reading; + stats.totalPractices += 1; + stats.totalQuestions += summary.totalQuestions; + stats.correctAnswers += summary.correctAnswers; + target.practices += 1; + target.questions += summary.totalQuestions; + target.correct += summary.correctAnswers; + } + stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0; + for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0; + stats.lastUpdated = nowIso(); + return stats; + } + + function validIso(value) { + if (value === null || value === undefined || value === '') return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function practiceType(record) { + const metadata = asObject(record.metadata); + const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType, + record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase(); + if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening'; + if (hints.includes('read')) return 'reading'; + return null; + } + + function accuracyRatio(record) { + const summary = lightFromCanonical(record); + const value = Number(summary.accuracy); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value > 1 ? value / 100 : value)); + } + + function durationSeconds(record) { + const scoreInfo = asObject(record.scoreInfo); + const realData = asObject(record.realData); + const realScoreInfo = asObject(realData.scoreInfo); + for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; + } + return 0; + } + + function earlierUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function laterUnlock(left, right) { + const leftIso = validIso(left); + const rightIso = validIso(right); + if (!leftIso) return rightIso; + if (!rightIso) return leftIso; + return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso; + } + + function computeAchievementProgress(records, manual, existing) { + const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord) + .map((record, index) => ({ + record, + index, + unlockedAt: validIso(record.completedAt || record.timestamp), + time: new Date(record.completedAt || record.timestamp).getTime() + })) + .sort((left, right) => { + const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER; + const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER; + return leftTime - rightTime || left.index - right.index; + }); + const candidates = {}; + const setThreshold = (id, list, count) => { + if (list.length >= count) candidates[id] = list[count - 1].unlockedAt; + }; + setThreshold('first_step', items, 1); + setThreshold('practice_bronze', items, 10); + setThreshold('practice_silver', items, 50); + setThreshold('practice_gold', items, 100); + setThreshold('practice_platinum', items, 200); + + const reading = items.filter((item) => practiceType(item.record) === 'reading'); + const listening = items.filter((item) => practiceType(item.record) === 'listening'); + setThreshold('reading_first', reading, 1); + setThreshold('reading_bronze', reading, 10); + setThreshold('reading_silver', reading, 50); + setThreshold('reading_gold', reading, 100); + setThreshold('listening_first', listening, 1); + setThreshold('listening_bronze', listening, 10); + setThreshold('listening_silver', listening, 50); + setThreshold('listening_gold', listening, 100); + if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt); + if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt); + + let cumulativeDuration = 0; + let cumulativeAccuracy = 0; + let perfectCount = 0; + let speedCount = 0; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const accuracy = accuracyRatio(item.record); + const duration = durationSeconds(item.record); + cumulativeDuration += duration; + cumulativeAccuracy += accuracy; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt; + if (accuracy >= 1) { + perfectCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt; + if (perfectCount === 3) candidates.perfect_three = item.unlockedAt; + if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt; + } + if (duration > 0 && duration <= 300 && accuracy > 0.8) { + speedCount += 1; + if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt; + if (speedCount === 3) candidates.speed_three = item.unlockedAt; + if (speedCount === 10) candidates.speed_ten = item.unlockedAt; + } + } + + const dayItems = new Map(); + for (const item of items) { + if (!item.unlockedAt) continue; + const day = item.unlockedAt.slice(0, 10); + if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt); + } + const days = Array.from(dayItems.keys()).sort(); + let streak = 0; + let previousDay = null; + for (const day of days) { + const currentDay = new Date(`${day}T00:00:00.000Z`).getTime(); + streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1; + previousDay = currentDay; + if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day); + if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day); + if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day); + if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day); + } + + const progress = {}; + const mergeUnlocked = (source) => { + for (const [rawId, value] of Object.entries(asObject(source))) { + if (!value || rawId === 'updatedAt') continue; + const id = rawId; + const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null; + if (!progress[id]) progress[id] = { unlockedAt }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + }; + mergeUnlocked(existing); + mergeUnlocked(manual); + for (const [id, unlockedAt] of Object.entries(candidates)) { + if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) }; + else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt); + } + return jsonValue(progress, 'achievement progress'); + } + + // Entity records are authoritative. Projections are assembled on reads, never cached or + // scheduled as follow-up work; this keeps a successful write immediately observable. + async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } + + async function retryMergeConflict(options, task, maxAttempts = 3) { + const explicitRevision = hasOwn(options, 'expectedRevision'); + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await task(); + } catch (error) { + lastError = error; + if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) { + throw error; + } + } + } + throw lastError; + } + + async function readCollectionMeta(logicalKey) { + const meta = await kernel.read(logicalKey, { withMeta: true }); + return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 }; + } + + function retainBackupEntries(items, limit = 20, preserveIds = []) { + const cap = Math.max(1, Number(limit) || 20); + const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || '')); + const entries = asArray(items).filter(Boolean).sort(newestFirst); + const retained = []; + const retainedIds = new Set(); + const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean)); + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + for (const item of entries) { + const id = String(item.id); + if (retained.length >= cap) break; + if (retainedIds.has(id)) continue; + retained.push(item); + retainedIds.add(id); + } + return retained; + } + + function hasOwn(value, key) { + return Boolean(value && Object.prototype.hasOwnProperty.call(value, key)); + } + + function normalizeLibraryConfigurationId(value) { + return importedLibraryId(value, { nullable: true }); + } + + async function practiceRecordWithLibraryProvenance(source, command, options = {}) { + assertObject(source, 'practice record must be an object'); + const record = jsonValue(source, 'practice record'); + const metadata = asObject(record.metadata); + let configurationId; + + if (hasOwn(command, 'libraryConfigurationId')) { + configurationId = command.libraryConfigurationId; + } else if (hasOwn(metadata, 'libraryConfigurationId')) { + configurationId = metadata.libraryConfigurationId; + } else if (hasOwn(record, 'libraryConfigurationId')) { + configurationId = record.libraryConfigurationId; + } else { + configurationId = await kernel.read('library.activeConfigurationId'); + } + + const normalizedId = normalizeLibraryConfigurationId(configurationId); + record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId }); + + if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) { + record.suiteEntries = record.suiteEntries.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry; + const next = jsonValue(entry, 'practice suite entry'); + const entryMetadata = asObject(next.metadata); + const entryId = hasOwn(entryMetadata, 'libraryConfigurationId') + ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId) + : normalizedId; + next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId }); + return next; + }); + } + + return record; + } + + function practiceRecordMatches(record, identities) { + const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean)); + if (!expected.size || !record || typeof record !== 'object') return false; + return ['id', 'recordId', 'sessionId'].some((field) => { + const value = record[field]; + return value !== undefined && value !== null && expected.has(String(value)); + }); + } + + function practiceLayerId(row) { + return String(row && (row.recordId || row.id || row.sessionId) || ''); + } + async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { + // The real kernel reads all three entity stores in one readonly + // IndexedDB transaction. Keep the fallback for deliberately minimal + // embedders and unit-test kernels that only expose the original methods. + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); + } + const ids = recordIds === null || recordIds === undefined + ? null + : (Array.isArray(recordIds) ? recordIds : [recordIds]) + .map((value) => String(value || '')) + .filter(Boolean); + const summaries = ids === null + ? await kernel.listEntities('practiceSummaries', { withMeta }) + : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); + const targetIds = summaries.map(practiceLayerId).filter(Boolean); + const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) + .then((rows) => rows.filter(Boolean)); + return { + practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], + practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], + practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] + }; + } + async function practiceLayers(recordId, withMeta = false) { + const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + return { + summary: find('practiceSummaries'), + detail: find('practiceDetails'), + annotations: find('practiceAnnotations') + }; + } + async function suiteChildRecordIds(command, aggregateRecordId) { + const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); + const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); + if (sessionIds.size) { + const summaries = await kernel.listEntities('practiceSummaries'); + for (const summary of summaries) { + if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); + } + } + ids.delete(String(aggregateRecordId)); + return ids; + } + function entityRevision(row) { return row ? Number(row.revision) : 0; } + function practiceUpserts(recordId, layers, existing = {}) { + return [ + { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) }, + { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) }, + { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) } + ]; + } + async function joinedPractice(recordId, projection, snapshot = null) { + const mode = String(projection || 'full').toLowerCase(); + const layers = snapshot || await practiceProjectionSnapshot([recordId], false, + mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : null)); + const summary = asArray(layers.practiceSummaries) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (!summary) return null; + if (mode === 'light' || mode === 'summary') return clone(summary); + const detail = asArray(layers.practiceDetails) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); + const annotations = asArray(layers.practiceAnnotations) + .find((row) => practiceLayerId(row) === String(recordId)) || null; + return joinPracticeRecord(summary, detail, annotations, mode); + } + function practiceSummaryTime(summary) { + const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); + return Number.isFinite(time) ? time : 0; + } + function isReadingInsightSummary(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => practiceType(entry) === 'reading'); + } + return practiceType(asObject(summary)) === 'reading'; + } + function needsQuestionTypeInsightBackfill(summary) { + const suiteEntries = asArray(summary && summary.suiteEntrySummaries); + if (suiteEntries.length) { + return suiteEntries.some((entry) => + practiceType(entry) === 'reading' + && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); + } + return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + } + const practice = Object.freeze({ + async list(options = {}) { + await ready; + const projection = String(options.projection || 'full').toLowerCase(); + const snapshot = await practiceProjectionSnapshot(null, false, + projection === 'light' || projection === 'summary' + ? ['practiceSummaries'] + : null); + const summaries = asArray(snapshot.practiceSummaries); + if (projection === 'light' || projection === 'summary') return summaries; + return (await Promise.all(summaries + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) + .filter(Boolean); + }, + async listInsights(options = {}) { + await ready; + const requestedLimit = Number(options.limit); + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, 50) + : 10; + const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); + const summaries = asArray(snapshot.practiceSummaries) + .filter(isReadingInsightSummary) + .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) + .slice(0, limit); + return summaries.map((summary) => { + if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); + const detail = asArray(snapshot.practiceDetails) + .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; + return detail + ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) + : clone(summary); + }); + }, + async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, + async completeAttempt(command) { + await ready; + const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command; + const mutation = mutationOptions(command, 'practice-complete', source); + const recordInput = await practiceRecordWithLibraryProvenance(source, command); + if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId); + const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities( + practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation)); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async finalizeSuite(command) { + await ready; assertObject(command, 'finalizeSuite command is required'); + const mutation = mutationOptions(command, 'practice-suite', command); + const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); + if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); + const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const receipt = await retryMergeConflict(command, async () => { + const existing = await practiceLayers(recordId, true); + const children = await suiteChildRecordIds(command, recordId); + const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); + return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); + }); + return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') }); + }, + async updateAnnotations(command) { + await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || ''); + return retryMergeConflict(command, async () => { + const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`); + if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`); + const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data))); + const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default'); + if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) { + if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`); + annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) }); + } else { + if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`); + annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) }); + Object.assign(annotations, clone(asObject(command.patch))); + } + return kernel.mutateEntities([{ + type: 'upsert', + store: 'practiceAnnotations', + recordId, + data: annotations, + expectedRevision: entityRevision(current.annotations) + }], mutationOptions(command, 'practice-annotations', command)); + }); + }, + async delete(command) { + await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required'); + const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId })); + return Object.assign({}, receipt, { deletedCount: 1 }); + }, + async deleteMany(command) { + await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean))); + if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id]))); + if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true }); + const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); + }, + async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, + projectLight, + projectDetail + }); + + const settings = Object.freeze({ + async getAll() { await ready; return kernel.read('settings.values'); }, + async patch(values, options = {}) { + await ready; assertObject(values, 'settings.patch requires an object'); + const mutation = optionsMutationOptions(options, 'settings-patch', values); + return retryMergeConflict(options, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + }); + }, + async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); } + }); + + const library = Object.freeze({ + async listConfigurations() { await ready; return kernel.read('library.configurations'); }, + async getActive() { await ready; return kernel.read('library.activeConfigurationId'); }, + async getIndex(configurationId) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + if (id === null) return []; + const indexes = await kernel.read('library.importedIndexes'); + return asArray(indexes[id]); + }, + async updateConfiguration(configuration, options = {}) { + await ready; assertObject(configuration, 'library.updateConfiguration requires an object'); + const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId'])); + const current = await kernel.read('library.configurations', { withMeta: true }); + const configs = asArray(current.data); + const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id); + const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id }); + if (index >= 0) configs[index] = next; else configs.push(next); + return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration)); + }, + async activate(configurationId, options = {}) { + await ready; + const id = importedLibraryId(configurationId, { nullable: true }); + const current = await kernel.read('library.activeConfigurationId', { withMeta: true }); + return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id })); + }, + async import(command) { + await ready; assertObject(command, 'library.import requires a command'); + const id = importedLibraryId(command.id || command.configurationId || randomId('library')); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id); + configs.push(Object.assign({}, asObject(command.configuration), { id, key: id })); + const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) }); + return kernel.mutate([ + { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ], mutationOptions(command, 'library-import', command)); + }, + async remove(configurationId, options = {}) { + await ready; const id = importedLibraryId(configurationId); + const configsMeta = await kernel.read('library.configurations', { withMeta: true }); + const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true }); + const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true }); + const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id]; + const changes = [ + { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 }, + { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 } + ]; + if (String(activeMeta.data || '') === id) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 }); + } + return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id })); + }, + async resolveIndex() { + await ready; + const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]); + return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]); + } + }); + + function recoveryKey(kind) { + const key = RECOVERY_KEYS[String(kind || '')]; + if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`); + return key; + } + // Recovery document TTL is an AppData domain rule, not a catalog policy field. + const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000; + function recoveryTimestamp(item) { + for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) { + const parsed = Date.parse(item && item[field]); + if (Number.isFinite(parsed)) return parsed; + } + return null; + } + async function pruneRecoveryKey(logicalKey) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await kernel.read(logicalKey, { withMeta: true }); + const items = asArray(current.data); + const cutoff = Date.now() - RECOVERY_TTL_MS; + const retained = items.filter((item) => { + const timestamp = recoveryTimestamp(item); + return timestamp === null || timestamp > cutoff; + }); + if (retained.length === items.length) return items; + try { + await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], { + operationId: randomId('recovery-ttl') + }); + return retained; + } catch (error) { + if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error; + } + } + return kernel.read(logicalKey); + } + async function cleanupExpiredRecovery() { + for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey); + } + const windowSession = Object.freeze({ + save(name, value) { + if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable'); + const logicalName = String(name || 'default'); + const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) }; + global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload)); + return true; + }, + get(name) { + if (!global.sessionStorage) return null; + const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + if (!raw) return null; + const payload = JSON.parse(raw); + return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null; + }, + discard(name) { + if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`); + return true; + } + }); + + const recoveryMutationTails = new Map(); + function enqueueRecoveryMutation(logicalKey, task) { + const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve(); + const result = previous.then(task, task); + recoveryMutationTails.set(logicalKey, result.catch(() => undefined)); + return result; + } + + async function readRecovery(kind, id) { + await ready; + const items = await pruneRecoveryKey(recoveryKey(kind)); + return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null; + } + async function saveRecovery(kind, value, options = {}) { + await ready; assertObject(value, `recovery ${kind} value must be an object`); + const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value); + const key = recoveryKey(kind); + const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId); + const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() }); + const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + if (index >= 0) current.items[index] = item; else current.items.push(item); + return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation); + })); + const committedItem = (await kernel.read(key)) + .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id); + return Object.assign({}, receipt, { item: clone(committedItem || item) }); + } + async function discardRecovery(kind, id, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id)); + return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation); + })); + } + async function clearRecovery(kind, options = {}) { + await ready; + const key = recoveryKey(kind); + const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind }); + return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => { + const current = await readCollectionMeta(key); + if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) { + throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision }); + } + return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation); + })); + } + async function clearAllRecovery(options = {}) { + const results = {}; + for (const kind of Object.keys(RECOVERY_KEYS)) { + results[kind] = await clearRecovery(kind, options); + } + return results; + } + const recovery = Object.freeze({ + windowSession, + async clear(options = {}) { return clearAllRecovery(options); }, + async listActiveSessions() { return readRecovery('activeSession'); }, + async getActiveSession(id) { return readRecovery('activeSession', id); }, + async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); }, + async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); }, + async listDrafts() { return readRecovery('draft'); }, + async getDraft(id) { return readRecovery('draft', id); }, + async saveDraft(value, options) { return saveRecovery('draft', value, options); }, + async discardDraft(id, options) { return discardRecovery('draft', id, options); }, + async listInterrupted() { return readRecovery('interrupted'); }, + async getInterrupted(id) { return readRecovery('interrupted', id); }, + async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); }, + async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); }, + async listRejectedCompletions() { return readRecovery('rejectedCompletion'); }, + async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); }, + async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); }, + async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); } + }); + + function isImportableEntry(entry) { + return entry + && entry.classification !== 'system' + && entry.classification !== 'session' + && entry.import !== 'ignore'; + } + + function isPlainImportObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function isV2SnapshotShape(parsed) { + return isPlainImportObject(parsed) + && parsed.format === 'ielts-atlas-data-v2' + && isPlainImportObject(parsed.envelopes) + && isPlainImportObject(parsed.entities); + } + + const POISONED_V2_WRAPPER_ALIASES = Object.freeze({ + 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']), + 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']), + 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state']) + }); + const LIBRARY_IMPORT_KEYS = Object.freeze([ + 'library.configurations', + 'library.importedIndexes', + 'library.activeConfigurationId' + ]); + + function parseLegacyImportValue(value) { + if (typeof internals.parseLegacyValue !== 'function') { + throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); + } + return internals.parseLegacyValue(value); + } + + function decodePoisonedDocument(logicalKey, wrapped) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; + if (!aliases || !isPlainImportObject(wrapped) + || !Object.prototype.hasOwnProperty.call(wrapped, 'key') + || !Object.prototype.hasOwnProperty.call(wrapped, 'value') + || !aliases.includes(String(wrapped.key))) { + return { matched: false, value: null }; + } + const decoded = parseLegacyImportValue(wrapped.value); + if (!isPlainImportObject(decoded)) return { matched: true, value: null }; + const overlay = {}; + for (const [key, value] of Object.entries(wrapped)) { + if (key === 'key' || key === 'value' || key === 'timestamp') continue; + overlay[key] = clone(value); + } + return { + matched: true, + value: Object.assign({}, decoded, overlay) + }; + } + + function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { + if (!envelope || envelope.state !== 'present') return envelope; + const wrapped = envelope.data; + const decoded = decodePoisonedDocument(logicalKey, wrapped); + if (!decoded.matched || !decoded.value) return envelope; + const entry = catalog.get(logicalKey); + const next = internals.makeEnvelope(entry, decoded.value, { + revision: Number(envelope.revision) || 1, + operationId: String(envelope.operationId || randomId('import-repair')), + updatedAt: envelope.updatedAt + }); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + return next; + } + + function validateLibraryImportBundle(envelopes) { + const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (!presentKeys.length) return { valid: true, presentKeys }; + if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { + return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; + } + const values = {}; + for (const key of LIBRARY_IMPORT_KEYS) { + const envelope = envelopes[key]; + values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; + } + const configurations = asArray(values['library.configurations']); + const indexes = asObject(values['library.importedIndexes']); + const configurationIds = new Set(); + for (const configuration of configurations) { + const source = asObject(configuration); + const id = idOf(source, ['id', 'key', 'configId']); + if (!id || !acceptedLibraryId(id) || source.builtIn === true + || !Array.isArray(indexes[id]) || !indexes[id].length) { + return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; + } + configurationIds.add(id); + } + for (const [id, index] of Object.entries(indexes)) { + if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { + return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; + } + } + const activeId = values['library.activeConfigurationId']; + if (activeId !== null && (!acceptedLibraryId(activeId) + || !configurationIds.has(String(activeId)) + || !Array.isArray(indexes[String(activeId)]) + || !indexes[String(activeId)].length)) { + return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; + } + return { valid: true, presentKeys }; + } + + function canonicalizeV2Import(parsed) { + const warnings = []; + const repairedKeys = []; + const ignoredKeys = []; + const envelopes = {}; + for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + if (logicalKey === 'library.activeConfigurationId' + && rawEnvelope && rawEnvelope.state === 'present' + && String(rawEnvelope.data) === '[object Object]') { + ignoredKeys.push(logicalKey); + warnings.push('Skipped poisoned active library id'); + continue; + } + const repairCount = repairedKeys.length; + const repaired = repairPoisonedImportEnvelope( + logicalKey, + clone(rawEnvelope), + repairedKeys, + warnings + ); + const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; + const isLegacyRowWrapper = isPlainImportObject(rawData) + && Object.prototype.hasOwnProperty.call(rawData, 'key') + && Object.prototype.hasOwnProperty.call(rawData, 'value') + && String(rawData.key || '').startsWith('exam_system_'); + if (isLegacyRowWrapper && repairedKeys.length === repairCount) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + envelopes[logicalKey] = repaired; + } + const library = parsed.scope === 'full' + ? validateLibraryImportBundle(envelopes) + : { valid: true, presentKeys: [] }; + if (!library.valid) { + for (const logicalKey of library.presentKeys) { + delete envelopes[logicalKey]; + ignoredKeys.push(logicalKey); + } + warnings.push(`Skipped unsafe library data: ${library.reason}`); + } + const exportableKeys = catalog.list() + .filter((entry) => entry.export === true && isImportableEntry(entry)) + .map((entry) => entry.logicalKey); + const missingKeys = parsed.scope === 'full' + ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) + : []; + if (parsed.scope === 'full' && missingKeys.length) { + warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); + } + return { + envelopes, + warnings, + repairedKeys, + ignoredKeys, + missingKeys, + declaredScope: parsed.scope, + effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, + trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length + ? 'trusted-full' + : 'degraded-partial' + }; + } + + function resolveImportReplaceFlags(options = {}) { + const source = asObject(options); + const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase(); + const replaceAll = source.replace === true; + return { + replaceDocuments: replaceAll, + // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge. + replacePractice: replaceAll || practiceMode === 'replace' + }; + } + + function pickFirstRecordArray(candidates) { + for (const candidate of asArray(candidates)) { + if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) { + return { source: candidate.source, records: candidate.records }; + } + } + return null; + } + + /** + * Historical v1 export shapes (opensource / pre-AppData-v2): + * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats } + * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? } + * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*) + * - bare array of records, or { records: [...] } + * Recognition only — no dual backend and no local store migration. + */ + function extractLegacyPracticeRecords(payload) { + const sources = []; + const add = (source, records) => { + if (Array.isArray(records) && records.some(isPlainImportObject)) { + sources.push({ source, records }); + } + }; + + if (Array.isArray(payload)) { + add('(root array)', payload); + } else if (isPlainImportObject(payload)) { + const preferred = pickFirstRecordArray([ + { source: 'practice_records', records: payload.practice_records }, + { source: 'practiceRecords', records: payload.practiceRecords }, + { source: 'records', records: payload.records } + ]); + if (preferred) add(preferred.source, preferred.records); + + const data = isPlainImportObject(payload.data) ? payload.data : null; + if (data) { + const nested = pickFirstRecordArray([ + { source: 'data.practice_records', records: data.practice_records }, + { source: 'data.practiceRecords', records: data.practiceRecords } + ]); + if (nested) add(nested.source, nested.records); + else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data); + else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data); + if (isPlainImportObject(data.exam_system_practice_records)) { + add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); + } + } + if (isPlainImportObject(payload.exam_system_practice_records)) { + add('exam_system_practice_records.data', payload.exam_system_practice_records.data); + } + } + + const seen = new Set(); + const records = []; + for (const entry of sources) { + for (const item of asArray(entry.records)) { + if (!isPlainImportObject(item)) continue; + const identity = idOf(item, ['id', 'recordId', 'sessionId']); + if (identity) { + if (seen.has(identity)) continue; + seen.add(identity); + } + records.push(item); + } + } + return { + records, + sources: sources.map((entry) => entry.source) + }; + } + + function entityRowFromLayer(recordId, data, operationId) { + const payload = jsonValue(data, 'import practice entity'); + return { + recordId: String(recordId), + revision: 1, + operationId: String(operationId || `import-${recordId}`), + updatedAt: nowIso(), + data: payload, + checksum: checksum(payload) + }; + } + + function convertLegacyPracticeImport(payload) { + const extracted = extractLegacyPracticeRecords(payload); + if (!extracted.records.length) { + throw new AppDataError( + 'VALIDATION', + 'Import file is neither a v2 snapshot nor a recognizable v1 practice export' + ); + } + + const entities = { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + }; + const warnings = []; + let skipped = 0; + + for (const raw of extracted.records) { + try { + const layers = splitPracticeRecord(raw); + const recordId = layers.summary.id; + const operationId = `import-v1-${recordId}`; + entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId)); + entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId)); + entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId)); + } catch (error) { + skipped += 1; + warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`); + } + } + + if (!entities.practiceSummaries.length) { + throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized'); + } + + const accepted = entities.practiceSummaries.length; + return { + format: 'v1', + scope: 'partial', + envelopes: {}, + entities, + checksum: null, + warnings, + practiceSummary: { + accepted, + importedCount: accepted, + skippedCount: skipped, + sources: extracted.sources.slice() + } + }; + } + + function parseImportPayload(payload) { + let parsed; + try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); } + catch (error) { + if (error instanceof AppDataError) throw error; + throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message }); + } + + // Bare record arrays are a historical import convenience (UI file pickers). + if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed); + if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object'); + + if (isV2SnapshotShape(parsed)) { + if (Number(parsed.schemaVersion) !== Number(catalog.version)) { + throw new AppDataError('VALIDATION', 'Import schema version mismatch'); + } + if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) { + throw new AppDataError('VALIDATION', 'Import checksum mismatch'); + } + if (parsed.scope !== 'full' && parsed.scope !== 'partial') { + throw new AppDataError('VALIDATION', 'Import scope must be full or partial'); + } + const scope = parsed.scope; + for (const [store, rows] of Object.entries(parsed.entities)) { + if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) { + throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`); + } + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) { + throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + } + } + } + if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) { + throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer'); + } + const canonical = canonicalizeV2Import(parsed); + return { + format: 'v2', + scope: canonical.effectiveScope, + declaredScope: canonical.declaredScope, + envelopes: canonical.envelopes, + entities: parsed.entities, + checksum: parsed.checksum, + warnings: canonical.warnings, + practiceSummary: null, + repairedKeys: canonical.repairedKeys, + ignoredKeys: canonical.ignoredKeys, + missingKeys: canonical.missingKeys, + trust: canonical.trust + }; + } + + // Explicit but malformed v2 claims must not fall through to legacy parsers. + if (parsed.format === 'ielts-atlas-data-v2') { + throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported'); + } + + return convertLegacyPracticeImport(parsed); + } + + function collectionIdentityFields(logicalKey) { + if (logicalKey === 'library.configurations') return ['id', 'key', 'configId']; + if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId']; + if (logicalKey === 'backups.entries') return ['id']; + if (logicalKey === 'vocab.words') return ['id', 'word', 'key']; + if (logicalKey === 'goals.items') return ['id', 'goalId']; + return ['id', 'sessionId', 'recordId']; + } + + function collectionIdentity(logicalKey, value) { + const identity = idOf(value, collectionIdentityFields(logicalKey)); + return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity; + } + + function mergeCollection(existing, incoming, logicalKey) { + const result = asArray(existing).map((item) => clone(item)); + const positions = new Map(); + result.forEach((item, index) => { + const identity = collectionIdentity(logicalKey, item); + if (identity) positions.set(identity, index); + }); + for (const rawItem of asArray(incoming)) { + const item = jsonValue(rawItem, `${logicalKey} item`); + const identity = collectionIdentity(logicalKey, item); + if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`); + if (positions.has(identity)) result[positions.get(identity)] = item; + else { + positions.set(identity, result.length); + result.push(item); + } + } + return result; + } + + function mergeImportValue(entry, existing, incoming) { + const policy = entry.import; + if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey); + if (policy === 'patch') { + if (Array.isArray(existing) || Array.isArray(incoming)) { + // Array-shaped keys should use merge-by-id; treat accidental patch as replace. + return clone(incoming); + } + return Object.assign({}, asObject(existing), asObject(incoming)); + } + if (policy === 'replace') return clone(incoming); + throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`); + } + + async function currentEntitySnapshot() { + if (typeof kernel.readPracticeSnapshot === 'function') { + return kernel.readPracticeSnapshot(null, { withMeta: true }); + } + const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); + const result = {}; + for (const store of PRACTICE_ENTITY_STORES) { + if (store === 'practiceSummaries') result[store] = summaries; + else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean); + } + return result; + } + function practiceEntityIds(rows) { + return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean)); + } + function assertPracticeEntitySetsMatch(entities, message) { + const expected = practiceEntityIds(entities.practiceSummaries); + for (const store of PRACTICE_ENTITY_STORES.slice(1)) { + const actual = practiceEntityIds(entities[store]); + if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) { + throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', { + counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size])) + }); + } + } + } + async function createImportPlan(parsed, options = {}) { + const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options); + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} }; + const revisionToken = { documents: {}, entities: {} }; + const keys = []; const clearedKeys = []; + const warnings = asArray(parsed.warnings).map(String); + for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) { + if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); + const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue; + if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`); + if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) { + warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); + continue; + } + const currentRead = await kernel.read(logicalKey, { withMeta: true }); + const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; + const currentEnvelope = currentRead && currentRead.envelope; + revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + let next = envelope; + if (!replaceDocuments && envelope.state === 'present') { + next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + } + snapshot.envelopes[logicalKey] = next; + keys.push(logicalKey); + if (next.state === 'cleared') clearedKeys.push(logicalKey); + } + + // Any successful practice import installs all three stores together. Merge + // may update a subset only when the final recordId sets remain identical. + const sourceStores = Object.keys(asObject(parsed.entities)); + let practiceExistingCount = null; + let practiceIncomingCount = null; + if (sourceStores.length) { + if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) { + throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations'); + } + const current = await currentEntitySnapshot(); + revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries( + asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0]) + )])); + practiceExistingCount = asArray(current.practiceSummaries).length; + practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length; + const existing = replacePractice + ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []])) + : current; + for (const store of PRACTICE_ENTITY_STORES) { + const rows = asArray(existing[store]).map(clone); + const positions = new Map(rows.map((row, index) => [String(row.recordId), index])); + for (const row of asArray(parsed.entities[store])) { + if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`); + const index = positions.get(String(row.recordId)); + if (index === undefined) { + positions.set(String(row.recordId), rows.length); + rows.push(clone(row)); + } else rows[index] = clone(row); + } + snapshot.entities[store] = rows; + } + assertPracticeEntitySetsMatch(snapshot.entities); + } + + snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities }); + const practiceSummary = parsed.practiceSummary + ? clone(parsed.practiceSummary) + : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries') + ? { + accepted: Number(practiceIncomingCount) || 0, + importedCount: Number(practiceIncomingCount) || 0, + skippedCount: 0, + existingCount: Number(practiceExistingCount) || 0, + incomingCount: Number(practiceIncomingCount) || 0, + finalCount: asArray(snapshot.entities.practiceSummaries).length, + removedCount: Math.max(0, (Number(practiceExistingCount) || 0) + - asArray(snapshot.entities.practiceSummaries).length) + } + : null); + if (practiceSummary && practiceSummary.existingCount === undefined) { + practiceSummary.existingCount = Number(practiceExistingCount) || 0; + practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0; + practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length; + practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount); + } + const destructive = clearedKeys.length > 0 + || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0); + return { + snapshot, + keys, + clearedKeys, + warnings, + practiceSummary, + destructive, + resetJournal: replaceDocuments && replacePractice, + revisionToken, + diagnostics: { + format: parsed.format, + replaceDocuments, + replacePractice, + declaredScope: parsed.declaredScope || parsed.scope, + effectiveScope: parsed.scope, + trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'), + missingKeys: clone(parsed.missingKeys || []), + repairedKeys: clone(parsed.repairedKeys || []), + ignoredKeys: clone(parsed.ignoredKeys || []) + } + }; + } + async function createRestoreSnapshot(backup) { + const parsed = parseImportPayload(asObject(backup && backup.data)); + if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups'); + if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch'); + return (await createImportPlan(parsed, { replace: true })).snapshot; + } + + const backups = Object.freeze({ + onDataCommitted(listener) { return kernel.onCommitted(listener); }, + async getSettings() { await ready; return kernel.read('backups.settings'); }, + async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); }, + async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); }, + async getImportHistory() { await ready; return kernel.read('backups.importHistory'); }, + async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); }, + async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); }, + async create(options = {}) { + await ready; const current = await readCollectionMeta('backups.entries'); + const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' }); + const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup')); + const existing = current.items.find((item) => String(item.id) === String(backupId)); + if (existing) { + if (String(existing.operationId || '') === String(mutation.operationId) + && String(existing.type || 'manual') === String(options.type || 'manual')) { + return clone(existing); + } + throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, { + backupId: String(backupId) + }); + } + const snapshot = await kernel.exportSnapshot(); + const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum }; + current.items.unshift(backup); + current.items = retainBackupEntries(current.items, 20, options.preserveIds); + await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation); + const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId)); + return clone(committed || backup); + }, + async list() { await ready; return kernel.read('backups.entries'); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); }, + async export(options = {}) { + await ready; + if (options.backupId !== undefined && options.backupId !== null) { + const backupId = String(options.backupId); + const stored = asArray(await kernel.read('backups.entries')) + .find((item) => String(item && item.id) === backupId); + if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`); + const portable = jsonValue(stored, 'stored backup export'); + if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) { + throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`); + } + return portable; + } + const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null; + const logicalKeys = domains + ? catalog.list() + .filter((entry) => domains.has(entry.owner) && entry.export === true) + .map((entry) => entry.logicalKey) + : null; + const entityStores = !domains || domains.has('practice') + ? undefined + : []; + return kernel.exportSnapshot(Object.assign( + logicalKeys ? { logicalKeys } : {}, + entityStores ? { entityStores } : {} + )); + }, + async previewImport(payload, options = {}) { + await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan'); + const cutoff = Date.now() - (30 * 60 * 1000); + for (const [id, existing] of importPlans) { + if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id); + } + const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) }; + importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive }; + }, + async commitImport(planId, options = {}) { + await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`); + if (plan.destructive && options.confirmDestructive !== true) { + throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation'); + } + const mutation = optionsMutationOptions(options, 'import-commit', { + planId: plan.id, + signature: plan.signature + }, { warnings: plan.warnings }); + const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, { + resetJournal: plan.resetJournal === true, + expectedRevisionToken: plan.revisionToken + })); + importPlans.delete(String(planId)); + return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) }); + }, + async restore(id, options = {}) { + await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id)); + if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`); + const snapshot = await createRestoreSnapshot(backup); + const restoreMutation = optionsMutationOptions(options, 'backup-restore', { + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }, { resetJournal: true }); + const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`; + const preRestoreBackupId = `pre_restore_${checksum({ + operationId: restoreMutation.operationId, + backupId: String(id), + checksum: backup.checksum || checksum(backup.data) + }).replace(/[^a-z0-9]/gi, '')}`; + const preRestoreBackup = await backups.create({ + id: preRestoreBackupId, + operationId: preRestoreOperationId, + type: 'pre-restore', + preserveIds: [String(id)] + }); + const receipt = await kernel.installSnapshot(snapshot, restoreMutation); + return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id }); + } + }); + + let vocabMutationTail = Promise.resolve(); + function enqueueVocabMutation(task) { + const result = vocabMutationTail.then(task, task); + vocabMutationTail = result.catch(() => undefined); + return result; + } + function retryVocabMutation(options, task) { + return enqueueVocabMutation(() => retryMergeConflict(options, task)); + } + + const vocab = Object.freeze({ + async listWords() { await ready; return kernel.read('vocab.words'); }, + async saveWords(words, options = {}) { + await ready; assertArray(words, 'vocab.saveWords requires an array'); + const mutation = optionsMutationOptions(options, 'vocab-words', words); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.words', { withMeta: true }); + return mutateAndProject([{ + logicalKey: 'vocab.words', + data: words, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async getConfig() { await ready; return kernel.read('vocab.userConfig'); }, + async setConfig(config, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'vocab-config', config); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: asObject(config), + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async patchConfig(patch, options = {}) { + await ready; assertObject(patch, 'vocab.patchConfig requires an object'); + const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.userConfig', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), clone(patch)); + return kernel.mutate([{ + logicalKey: 'vocab.userConfig', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); }, + async listCollections() { await ready; return kernel.read('vocab.lists'); }, + async saveCollection(id, value, options = {}) { + await ready; + const collectionId = String(id); + const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async saveCollections(values, options = {}) { + await ready; + assertObject(values, 'vocab.saveCollections requires an object'); + const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)])); + const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), upserts); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: next, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async upsertCollectionWord(collectionId, word, options = {}) { + await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word'); + const id = String(collectionId || ''); + if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required'); + const identity = String(word.word || word.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word }); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + const existing = collections[id]; + const list = existing && typeof existing === 'object' && !Array.isArray(existing) + ? Object.assign({}, clone(existing), { words: asArray(existing.words) }) + : { id, words: asArray(existing) }; + const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity); + const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() }); + if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt; + if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); + list.updatedAt = nowIso(); + collections[id] = list; + const receipt = await mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(nextWord) }); + }); + }, + async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; }, + async replaceListWords(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceListWords requires a command'); + const id = String(command.listId || 'default'); const words = asArray(command.words); + if (id === 'default') return this.saveWords(words, options); + const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read('vocab.lists', { withMeta: true }); + const collections = Object.assign({}, asObject(current.data)); + collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); + return mutateAndProject([{ + logicalKey: 'vocab.lists', + data: collections, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + }); + }, + async mergeListWords(command, options = {}) { + await ready; + assertObject(command, 'vocab.mergeListWords requires a command'); + const listId = String(command.listId || 'default'); + const incoming = asArray(command.words); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions(options, 'vocab-words-merge', command); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data)); + const storedList = listId === 'default' + ? asArray(current.data) + : (function readStoredCollection() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? asArray(collection.words) + : asArray(collection); + }()); + const merged = storedList.map((word) => clone(word)); + const positions = new Map(); + merged.forEach((word, index) => { + const identity = String(word && (word.word || word.id) || '').trim().toLowerCase(); + if (identity) positions.set(identity, index); + }); + let addedCount = 0; + let updatedCount = 0; + for (const rawWord of incoming) { + assertObject(rawWord, 'vocab.mergeListWords entries must be objects'); + const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase(); + if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required'); + if (!positions.has(identity)) { + positions.set(identity, merged.length); + merged.push(clone(rawWord)); + addedCount += 1; + continue; + } + const index = positions.get(identity); + const existing = asObject(merged[index]); + const patch = {}; + if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim(); + if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim(); + if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq; + merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() }); + updatedCount += 1; + } + const data = listId === 'default' + ? merged + : Object.assign({}, collections, { + [listId]: Object.assign( + {}, + (function collectionBaseForWrite() { + const collection = collections[listId]; + return collection && typeof collection === 'object' && !Array.isArray(collection) + ? clone(collection) + : {}; + }()), + { id: listId, words: merged, updatedAt: nowIso() } + ) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) + }], mutation); + return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount }); + }); + }, + async patchWord(command, options = {}) { + await ready; assertObject(command, 'vocab.patchWord requires a command'); + const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || ''); + if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required'); + const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists'; + const mutation = optionsMutationOptions( + Object.assign({}, options, { operationId: command.operationId || options.operationId }), + 'vocab-word-patch', + command + ); + return retryVocabMutation(options, async () => { + const current = await kernel.read(logicalKey, { withMeta: true }); + const collections = listId === 'default' ? null : asObject(current.data); + const list = listId === 'default' + ? asArray(current.data) + : asArray(asObject(collections[listId]).words); + const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId); + if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`); + const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() }); + const next = list.slice(); next[index] = updated; + const data = listId === 'default' + ? next + : Object.assign({}, collections, { + [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) + }); + const receipt = await mutateAndProject([{ + logicalKey, + data, + expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) + }], mutation); + return Object.assign({}, receipt, { word: clone(updated) }); + }); + }, + async replaceProgress(command, options = {}) { + await ready; assertObject(command, 'vocab.replaceProgress requires a command'); + const listId = String(command.listId || 'default'); const words = asArray(command.words); + const mutation = optionsMutationOptions(options, 'vocab-progress', command); + return retryVocabMutation(options, async () => { + const configMeta = await kernel.read('vocab.userConfig', { withMeta: true }); + const changes = [{ + logicalKey: 'vocab.userConfig', + data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }), + expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0 + }]; + if (listId === 'default') { + const wordsMeta = await kernel.read('vocab.words', { withMeta: true }); + changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 }); + } else { + const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data)); + lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); + changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); + } + return mutateAndProject(changes, mutation); + }); + } + }); + + async function readPreferences() { await ready; return kernel.read('preferences.values'); } + let preferenceMutationTail = Promise.resolve(); + function enqueuePreferenceMutation(task) { + const result = preferenceMutationTail.then(task, task); + preferenceMutationTail = result.catch(() => undefined); + return result; + } + async function writePreference(field, value, options = {}) { + const mutation = optionsMutationOptions(options, 'preference-set', { field, value }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const next = Object.assign({}, asObject(current.data), { [field]: clone(value) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + async function patchPreference(field, patch, options = {}) { + await ready; + const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch }); + return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => { + const current = await kernel.read('preferences.values', { withMeta: true }); + const values = asObject(current.data); + const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) }); + return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation); + })); + } + const preferences = Object.freeze({ + async getAll() { return readPreferences(); }, + async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); }, + async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); }, + async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); }, + async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); }, + async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); } + ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); }, + async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); }, + async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); }, + async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); }, + async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); }, + async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); }, + async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); }, + async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); } + }); + + const goals = Object.freeze({ + async list() { await ready; return kernel.read('goals.items'); }, + async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); }, + async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); } + }); + + function deliveryTimestamp(value) { + const candidate = value && typeof value === 'object' ? value.unlockedAt : value; + const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN; + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function mergeDeliveryAcknowledgements(current, incoming) { + const merged = Object.assign({}, asObject(current)); + for (const [id, value] of Object.entries(asObject(incoming))) { + const key = String(id).trim(); + if (!key) continue; + const previous = deliveryTimestamp(merged[key]); + const next = deliveryTimestamp(value); + if (!hasOwn(merged, key) || (next && (!previous || next < previous))) { + merged[key] = next; + } else if (previous) { + merged[key] = previous; + } else { + merged[key] = null; + } + } + return merged; + } + + const achievements = Object.freeze({ + async getAll() { + await ready; + const progress = await retryMergeConflict({}, async () => { + const [summaries, manual, current] = await Promise.all([ + kernel.listEntities('practiceSummaries'), + kernel.read('achievements.manual'), + kernel.read('achievements.progress', { withMeta: true }) + ]); + const projected = asObject(computeAchievementProgress(summaries, manual, current.data)); + if (checksum(projected) !== checksum(asObject(current.data))) { + await kernel.mutate([{ + logicalKey: 'achievements.progress', + data: projected, + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], { + operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}` + }); + } + return projected; + }, 5); + if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh; + Object.defineProperty(progress, 'fresh', { value: true, enumerable: false }); + return progress; + }, + async retryPending() { return achievements.getAll(); }, + async acknowledgeDelivery(unlocked, options = {}) { + await ready; + assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object'); + const requested = clone(unlocked); + const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested); + return retryMergeConflict({}, async () => { + const current = await kernel.read('settings.values', { withMeta: true }); + const settingsValue = asObject(current.data); + const delivery = asObject(settingsValue.achievementDelivery); + const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested); + return kernel.mutate([{ + logicalKey: 'settings.values', + data: Object.assign({}, settingsValue, { + achievementDelivery: { version: 1, acknowledged } + }), + expectedRevision: current.envelope ? Number(current.envelope.revision) : 0 + }], mutation); + }, 5); + }, + async getManualState() { await ready; return kernel.read('achievements.manual'); } + }); + + const LEGACY_DOCUMENT_ALIASES = Object.freeze({ + 'settings.values': ['user_settings', 'settings', 'system_settings'], + 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'], + 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'], + 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'], + 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'], + 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'], + 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], + 'achievements.manual': ['achievement_manual_state', 'user_achievements'] + }); + const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ + 'recovery.activeSessions', + 'recovery.drafts', + 'recovery.interrupted', + 'recovery.rejectedCompletions' + ]); + const LEGACY_PREFERENCE_ALIASES = Object.freeze({ + theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', + practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', + ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' + }); + + function legacyRecordArray(value) { + return Array.isArray(value) ? value : asArray(asObject(value).data); + } + function mergeLegacyExternalBackup(legacyValue, externalValue) { + const legacy = Object.assign({}, asObject(legacyValue)); + const external = asObject(externalValue); + const externalRecordKey = ['practice_records', 'practiceRecords'] + .find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (externalRecordKey) { + const records = new Map(); + for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { + const recordId = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); + } + legacy.practice_records = Array.from(records.values()); + } + for (const [target, aliases] of Object.entries({ + user_stats: ['user_stats', 'userStats'], + exam_index: ['exam_index', 'examIndex'], + storage_version: ['storage_version', 'storageVersion'] + })) { + if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); + if (alias) legacy[target] = clone(external[alias]); + } + return legacy; + } + + function acceptedLibraryId(value) { + const id = value === null || value === undefined ? '' : String(value).trim(); + if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; + try { return importedLibraryId(id); } catch (_) { return null; } + } + + function remapLegacyLibraryId(value) { + return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + } + + async function migrateLegacyLibraryData(legacy) { + const [configMeta, indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.configurations', { withMeta: true }), + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const legacyIdMap = new Map(); + const indexes = {}; + const addLegacyIndex = (oldId, value) => { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; + const index = asArray(value); + if (!index.length) return; + const mappedId = remapLegacyLibraryId(oldId); + legacyIdMap.set(oldId, mappedId); + indexes[mappedId] = clone(index); + }; + + for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); + // Reconciliation is a union. A healthy current v2 value wins for the same + // deterministic library ID, while missing legacy libraries are restored. + for (const [id, value] of Object.entries(asObject(indexMeta.data))) { + if (/^exam_index_/.test(id)) addLegacyIndex(id, value); + else { + const acceptedId = acceptedLibraryId(id); + if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); + } + } + + const configurations = new Map(); + const addConfiguration = (configuration) => { + const source = asObject(configuration); + const oldId = idOf(source, ['id', 'key', 'configId']); + if (!oldId || oldId === 'exam_index') return; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + if (!id || !asArray(indexes[id]).length) return; + configurations.set(id, Object.assign({}, clone(source), { + id, + key: id, + examCount: indexes[id].length + })); + }; + asArray(legacy.exam_index_configurations).forEach(addConfiguration); + asArray(configMeta.data).forEach(addConfiguration); + for (const [oldId, id] of legacyIdMap) { + if (!configurations.has(id)) { + configurations.set(id, { + id, + key: id, + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' + }); + } + } + + const resolveActive = (value) => { + const oldId = value === null || value === undefined ? '' : String(value).trim(); + if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; + const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); + return id && asArray(indexes[id]).length ? id : null; + }; + const currentRawActive = activeMeta.data; + let activeId = resolveActive(currentRawActive); + const currentIsExplicitDefault = Boolean(activeMeta.envelope) + && (currentRawActive === null || String(currentRawActive || '').trim() === ''); + if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { + activeId = resolveActive(legacy.active_exam_index_key); + } + + const nextConfigurations = Array.from(configurations.values()); + const changes = []; + if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { + changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); + } + if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { + changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); + } + if (activeId !== activeMeta.data) { + changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); + } + if (changes.length) { + await kernel.mutate(changes, { + operationId: `legacy-library-repair-v2-${checksum(changes)}` + }); + } + } + + async function migrateLegacyData() { + // Unit embedders may provide a deliberately minimal kernel bootstrap. + if (typeof internals.readLegacyValues !== 'function') return; + const legacySource = await internals.readLegacyValues(); + if (legacySource && legacySource.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); + const migrationState = asObject(migrationMeta.data); + let externalBackup = null; + if (asObject(migrationState.externalBackupV1).status !== 'consumed' + && typeof internals.readLegacyExternalBackup === 'function') { + try { + externalBackup = await internals.readLegacyExternalBackup(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); + } + } + const legacy = externalBackup + ? mergeLegacyExternalBackup(legacySource, externalBackup) + : legacySource; + if (!legacy || !Object.keys(legacy).length) return; + + const documentMetas = {}; + for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { + documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); + } + const [indexMeta, activeMeta] = await Promise.all([ + kernel.read('library.importedIndexes', { withMeta: true }), + kernel.read('library.activeConfigurationId', { withMeta: true }) + ]); + const documentRepairs = []; + const poisonedDocumentKeys = []; + for (const [logicalKey, current] of Object.entries(documentMetas)) { + if (!current.envelope || current.envelope.state !== 'present') continue; + const decoded = decodePoisonedDocument(logicalKey, current.data); + if (!decoded.matched) continue; + poisonedDocumentKeys.push(logicalKey); + const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; + const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + const legacyValue = legacyAlias ? legacy[legacyAlias] : null; + let repairValue = decoded.value; + if (isPlainImportObject(legacyValue)) { + repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); + } + if (!repairValue) continue; + documentRepairs.push({ + logicalKey, + data: repairValue, + expectedRevision: Number(current.envelope.revision) + }); + } + const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => + isPlainImportObject(value) + && Object.prototype.hasOwnProperty.call(value, 'key') + && Object.prototype.hasOwnProperty.call(value, 'value') + && /^exam_system_exam_index_/.test(String(value.key || ''))); + const poisonedActive = String(activeMeta.data) === '[object Object]'; + const libraryPoisoned = poisonedIndex || poisonedActive; + const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; + + await migrateLegacyLibraryData(legacy); + if (documentRepairs.length) { + await kernel.mutate(documentRepairs, { + operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` + }); + } + + const changes = []; + for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { + const currentAudit = asObject(migrationState.v1ToV2); + if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { + continue; + } + const current = await kernel.getEnvelope(logicalKey); + const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); + if (!alias) continue; + const legacyValue = legacy[alias]; + if (!current) { + changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); + continue; + } + const isBadMigrationWrite = current.state === 'present' + && /^legacy-documents-/.test(String(current.operationId || '')); + if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { + changes.push({ + logicalKey, + data: legacyValue, + expectedRevision: Number(current.revision) + }); + continue; + } + const entry = catalog.get(logicalKey); + if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; + let merged; + try { + merged = mergeImportValue(entry, legacyValue, current.data); + } catch (error) { + if (global.console && console.warn) { + console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); + } + continue; + } + if (checksum(merged) !== checksum(current.data)) { + changes.push({ + logicalKey, + data: merged, + expectedRevision: Number(current.revision) + }); + } + } + if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { + const preferences = {}; + for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) { + if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue; + const path = target.split('.'); let cursor = preferences; + path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; }); + cursor[path[path.length - 1]] = clone(legacy[alias]); + } + if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 }); + } + if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { + changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); + } + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + const recordsValue = legacy.practice_records; + const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); + const operations = []; + let skippedRecords = 0; + const reconciledRecordIds = new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + let canonical; + let parts; + try { + const candidate = jsonValue(record, 'legacy practice record'); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { + candidate.id = `legacy_${index}_${internals.checksum(record)}`; + } + canonical = canonicalizeRecord(candidate); + parts = splitPracticeRecord(canonical); + } catch (error) { + skippedRecords += 1; + if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); + continue; + } + if (reconciledRecordIds.has(canonical.id)) continue; + reconciledRecordIds.add(canonical.id); + // Storage errors are not malformed records. Let them abort this repair so the + // completion marker is not written and the next startup can retry safely. + const existing = await practiceLayers(canonical.id, true); + if (existing.summary && existing.detail && existing.annotations) continue; + operations.push(...practiceUpserts(canonical.id, parts, existing)); + } + if (operations.length) { + await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + } + const migrationAudit = { + version: 4, + status: 'complete', + mode: 'persistent-reconcile', + sourceChecksum: checksum(legacy), + sourceRecordCount: records.length, + skippedRecordCount: skippedRecords + }; + const currentAudit = asObject(migrationState.v1ToV2); + const comparableCurrentAudit = { + version: currentAudit.version, + status: currentAudit.status, + mode: currentAudit.mode, + sourceChecksum: currentAudit.sourceChecksum, + sourceRecordCount: currentAudit.sourceRecordCount, + skippedRecordCount: currentAudit.skippedRecordCount + }; + const externalAudit = externalBackup ? { + version: 1, + status: 'consumed', + sourceChecksum: checksum(externalBackup) + } : null; + const currentExternalAudit = asObject(migrationState.externalBackupV1); + if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) + || (externalAudit && (currentExternalAudit.status !== externalAudit.status + || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { + const nextMigrationState = Object.assign({}, migrationState, { + v1ToV2: Object.assign({}, migrationAudit, { + completedAt: nowIso(), + poisonDetected, + poisonedDocumentKeys, + libraryPoisoned + }) + }); + if (externalAudit) { + nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); + } + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { + operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` + }); + } + } + + const ready = kernel.initialize() + .then(async () => { + // Legacy migration and recovery cleanup are best-effort: a failure here + // (e.g. one malformed v1 record) must not brick the data layer for every + // read that awaits `ready`. Only a genuine backend init failure below is fatal. + try { + await migrateLegacyData(); + } catch (error) { + if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error); + } + try { + await cleanupExpiredRecovery(); + } catch (error) { + if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error); + } + return true; + }) + .catch((error) => { + if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error); + throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed'); + }); + + const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements }; + Object.defineProperties(AppData, { + ready: { value: ready, enumerable: false }, + status: { value: () => kernel.status(), enumerable: false } + }); + Object.freeze(AppData); + Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false }); + if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) { + throw new Error('AppData v2 failed to close its internal bootstrap channel'); + } + if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) { + throw new Error('AppData v2 failed to close its catalog bootstrap channel'); + } +})(typeof window !== 'undefined' ? window : globalThis); + + /* ===== js/runtime/readingExamRegistry.js ===== */ (function initReadingExamRegistry(global) { 'use strict'; @@ -260,6 +4245,7 @@ scope, text, kind: resolveHighlightKind(node), + noteId: node.dataset && node.dataset.noteId ? String(node.dataset.noteId) : '', occurrence: seen, start: startOffset, end: endOffset, @@ -333,6 +4319,9 @@ if (offsetRange && !offsetRange.collapsed) { const offsetSpan = document.createElement('span'); applyHighlightKind(offsetSpan, highlightKind); + if (record.noteId) { + offsetSpan.dataset.noteId = String(record.noteId); + } try { offsetRange.surroundContents(offsetSpan); return true; @@ -381,6 +4370,9 @@ } const span = document.createElement('span'); applyHighlightKind(span, highlightKind); + if (record.noteId) { + span.dataset.noteId = String(record.noteId); + } try { range.surroundContents(span); return true; @@ -782,7 +4774,20 @@ function compareAnswers(userAnswer, correctAnswer) { const expected = splitAnswerTokens(correctAnswer); - const actual = splitAnswerTokens(userAnswer); + let actual = splitAnswerTokens(userAnswer); + + if ( + expected.length === 1 + && /^[A-Z]$/.test(expected[0]) + && actual.length === 1 + && !/^[A-Z]$/.test(actual[0]) + && typeof userAnswer === 'string' + ) { + const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/); + if (labeledOption) { + actual = [labeledOption[1]]; + } + } if (expected.length === 0 && actual.length === 0) { return null; @@ -1233,12 +5238,12 @@ const BUBBLE_ID = 'review-highlight-dictionary-bubble'; const INTERACTIVE_CLASS = 'review-dictionary-highlight'; const VOCAB_MESSAGE_TYPE = 'VOCAB_HIGHLIGHT_SAVE'; - const FALLBACK_STORAGE_KEY = 'exam_system_vocab_list_reading_highlights'; let currentOptions = {}; let activeHighlight = null; let activeLookup = null; let outsideHandlerAttached = false; + const pendingSaveRequests = new Map(); function cleanText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); @@ -1673,48 +5678,12 @@ }; } - function createStorageEnvelope(data) { - return JSON.stringify({ - data, - timestamp: Date.now(), - version: '0.6.2-fix', - compressed: false - }); - } - - function readFallbackList() { - try { - const raw = global.localStorage && global.localStorage.getItem(FALLBACK_STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw); - const data = parsed && Object.prototype.hasOwnProperty.call(parsed, 'data') - ? parsed.data - : parsed; - return data && typeof data === 'object' && Array.isArray(data.words) ? data : null; - } catch (_) { - return null; - } - } - - function writeFallbackVocab(payload) { - if (!global.localStorage || !payload || !payload.word) { - return false; - } + async function writeAppDataVocab(payload) { + if (!payload || !payload.word || !global.AppData || !global.AppData.vocab) return false; + const key = String(payload.word).trim().toLowerCase(); const now = new Date().toISOString(); - const list = readFallbackList() || { - id: 'reading-highlights', - name: '阅读高亮生词', - icon: '📖', - source: 'reading-highlight', - words: [], - createdAt: now, - updatedAt: now - }; - const key = payload.word.toLowerCase(); - const existingIndex = list.words.findIndex((item) => String(item.word || '').trim().toLowerCase() === key); - const wordRecord = { + await global.AppData.ready; + await global.AppData.vocab.upsertCollectionWord('reading-highlights', { id: `reading-highlight-${key.replace(/[^a-z0-9]+/g, '-')}`, word: payload.word, meaning: payload.meaning || payload.definition || '待补充释义', @@ -1725,7 +5694,6 @@ payload.selectedText && payload.selectedText !== payload.word ? `原高亮: ${payload.selectedText}` : '', payload.sourceLabel ? `来源: ${payload.sourceLabel}` : '' ].filter(Boolean).join(';'), - timestamp: Date.now(), source: 'reading-highlight', easeFactor: null, interval: 1, @@ -1734,59 +5702,74 @@ correctCount: 0, lastReviewed: null, nextReview: null, - createdAt: existingIndex >= 0 ? (list.words[existingIndex].createdAt || now) : now, updatedAt: now - }; - if (existingIndex >= 0) { - list.words.splice(existingIndex, 1, { ...list.words[existingIndex], ...wordRecord }); - } else { - list.words.push(wordRecord); + }); + return true; + } + + function createRequestId() { + try { + if (global.crypto && typeof global.crypto.randomUUID === 'function') { + return `vocab-highlight-${global.crypto.randomUUID()}`; + } + } catch (_) { + // use timestamp fallback } - list.updatedAt = now; - list.stats = { - totalWords: list.words.length, - masteredWords: list.words.filter((word) => (Number(word.correctCount) || 0) >= 4).length, - reviewingWords: list.words.filter((word) => word.lastReviewed && !word.nextReview).length - }; - global.localStorage.setItem(FALLBACK_STORAGE_KEY, createStorageEnvelope(list)); + return `vocab-highlight-${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + function settleSaveRequest(requestId, succeeded) { + const id = String(requestId || '').trim(); + const pending = pendingSaveRequests.get(id); + if (!id || !pending) return false; + pendingSaveRequests.delete(id); + clearTimeout(pending.timer); + pending.resolve(Boolean(succeeded)); return true; } + function handleSaveOutcome(payload, succeeded) { + const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : ''; + return settleSaveRequest(requestId, succeeded); + } + function postVocabPayload(payload) { - if (currentOptions && typeof currentOptions.postMessage === 'function') { - currentOptions.postMessage(VOCAB_MESSAGE_TYPE, payload); - return true; + if (!currentOptions || typeof currentOptions.postMessage !== 'function') return null; + const requestId = createRequestId(); + const requestPayload = { ...payload, requestId }; + const outcome = new Promise((resolve) => { + const timer = setTimeout(() => { + pendingSaveRequests.delete(requestId); + resolve(false); + }, 5000); + pendingSaveRequests.set(requestId, { resolve, timer }); + }); + let delivered = false; + try { + delivered = currentOptions.postMessage(VOCAB_MESSAGE_TYPE, requestPayload) !== false; + } catch (_) { + delivered = false; } - const candidates = [global.opener, global.parent]; - for (let index = 0; index < candidates.length; index += 1) { - const target = candidates[index]; - if (!target || target === global) { - continue; - } - try { - target.postMessage({ - type: VOCAB_MESSAGE_TYPE, - source: 'practice_page', - data: payload - }, '*'); - return true; - } catch (_) { - // try next target - } + if (!delivered) { + settleSaveRequest(requestId, false); + return null; } - return false; + return outcome; } - function saveActiveLookup(button) { + async function saveActiveLookup(button) { const payload = buildVocabPayload(); if (!payload.word) { return; } - const posted = postVocabPayload(payload); - const fallbackSaved = writeFallbackVocab(payload); + const hostOutcome = postVocabPayload(payload); + let persisted = hostOutcome ? await hostOutcome : false; + if (!persisted) { + try { persisted = await writeAppDataVocab(payload); } catch (_) { persisted = false; } + } if (button instanceof HTMLButtonElement) { - button.textContent = posted || fallbackSaved ? '已加入' : '保存失败'; - button.disabled = true; + button.textContent = persisted ? '已加入' : '保存失败'; + button.disabled = persisted; } } @@ -1838,7 +5821,7 @@ attach, enhance, close: closeBubble, - storageKey: FALLBACK_STORAGE_KEY, + handleSaveOutcome, messageType: VOCAB_MESSAGE_TYPE }; @@ -1854,8 +5837,6 @@ (function initPracticeTimerPreferences(global) { 'use strict'; - var READING_KEY = 'ielts_reading_timer_preferences_v2'; - var LISTENING_KEY = 'ielts_listening_timer_preferences_v1'; var VERSION = 1; var DEFAULTS = { version: VERSION, @@ -1892,26 +5873,38 @@ }; } - function keyFor(scope) { - return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY; + var cache = Object.create(null); + var hydrationPromise = null; + function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; } + function hydrateTimerPreferences() { + if (cache.reading && cache.listening) return Promise.resolve(true); + if (hydrationPromise) return hydrationPromise; + if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false); + hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getTimer(); + cache.reading = normalize(stored && stored.reading); + cache.listening = normalize(stored && stored.listening); + return true; + }).catch(function onTimerPreferenceLoadError(error) { + hydrationPromise = null; + console.warn('[PracticeTimerPreferences] 加载失败:', error); + return false; + }); + return hydrationPromise; } function read(scope) { - try { - var raw = global.localStorage && global.localStorage.getItem(keyFor(scope)); - return normalize(raw ? JSON.parse(raw) : null); - } catch (_) { - return normalize(null); - } + return normalize(cache[normalizeScope(scope)]); } - function save(scope, preferences) { + async function save(scope, preferences) { + await hydrateTimerPreferences(); + if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable'); + var normalizedScope = normalizeScope(scope); var next = normalize(preferences); - try { - if (global.localStorage) { - global.localStorage.setItem(keyFor(scope), JSON.stringify(next)); - } - } catch (_) { } + await global.AppData.preferences.setTimer(normalizedScope, next); + cache[normalizedScope] = next; return next; } @@ -1919,17 +5912,16 @@ return clampMinutes(value, DEFAULTS.countdownMinutes) * 60; } - global.PracticeTimerPreferences = { + var api = { VERSION: VERSION, - READING_KEY: READING_KEY, - LISTENING_KEY: LISTENING_KEY, DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)), normalize: normalize, read: read, save: save, - keyFor: keyFor, minutesToSeconds: minutesToSeconds }; + Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences }); + global.PracticeTimerPreferences = api; })(typeof window !== 'undefined' ? window : globalThis); @@ -1940,12 +5932,33 @@ const MESSAGE_SOURCE = 'practice_page'; const INIT_RETRY_MS = 1500; const SIMULATION_DRAFT_SYNC_MS = 1200; + const READING_DRAFT_SYNC_MS = 1500; + const SUBMIT_ACK_TIMEOUT_MS = 10000; + const NOTE_EDITOR_SAVE_DEBOUNCE_MS = 450; + const NOTE_ROW_LONG_PRESS_MS = 100; const EXPLANATION_STYLE_ID = 'reading-explanation-style'; const MEMORIZE_STYLE_ID = 'reading-memorize-style'; + const READING_NOTE_STYLE_ID = 'reading-note-style'; + const READING_DISPLAY_CONTROL_STYLE_ID = 'reading-display-control-style'; const PRACTICE_TIMER_BRIDGE_KEY = '__IELTS_PRACTICE_TIMER__'; const PRACTICE_TIMER_EVENT = 'practiceTimerStateChange'; - const READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1'; const READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/; + const HOST_MESSAGE_SOURCE = 'exam_host'; + let readingCandidateCodeCache = { mode: 'auto', customCode: '' }; + + function deriveReferrerOrigin() { + try { + if (!document.referrer) return ''; + const parsed = new URL(document.referrer, global.location.href); + // File-page refs do not provide a usable web origin, so bind them through + // the opaque/file message-origin handling below instead of pinning file://. + if (parsed.protocol === 'file:') return ''; + if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return ''; + return parsed.origin; + } catch (_) { + return ''; + } + } const EXPLANATION_NODE_SELECTOR = [ '.reading-explanation-card', '.reading-group-explanation', @@ -1962,6 +5975,7 @@ const navStatus = new Map(); const scriptCache = new Map(); const LOCATOR_HIGHLIGHT_SELECTOR = '.reading-locator-highlight, .reading-locator-block'; + const LOCATOR_OVERLAP_SELECTOR = '.reading-locator-overlap'; function getAnswerMatchCore() { const core = global.AnswerMatchCore; if (!core || typeof core !== 'object') { @@ -2015,6 +6029,10 @@ timerLocked: false, ready: false, submitted: false, + submissionStatus: 'draft', + submissionId: '', + submissionAckTimer: null, + pendingSubmissionPresentation: null, initTimer: null, manifestLoaded: false, dataset: null, @@ -2036,10 +6054,37 @@ }, simulationDraftSyncTimer: null, simulationDraftFingerprint: '', + readingDraftSyncTimer: null, + readingDraftFingerprint: '', + notes: [], + noteOutlines: [], + markedQuestions: [], + activeNoteId: '', + noteEditorPosition: null, + noteUiInitialized: false, + noteEditorSaveTimer: null, + noteDrawerDirty: true, + noteHighlightMetaDirty: true, + noteEditorPendingSync: false, + reviewRecordId: '', + // 单篇阅读 final-submit 成功后,宿主通过 PRACTICE_RECORD_SAVED 回传的已存档 + // practice record id。持有该 id 时,笔记编辑在只读提交页仍然可写,并且 + // syncReadingAnnotation 会以该 recordId 发送 READING_ANNOTATION_SYNC,把 + // 结果页上的笔记改动持久化回已存档的练习记录。 + submittedRecordId: '', + highlightVisibility: { + locators: true, + notes: true, + highlights: true + }, + questionNavCollapsed: false, lastInitSignature: '', lastReplaySignature: '', sessionReadySent: false, parentWindow: global.opener || global.parent || null, + expectedParentOrigin: deriveReferrerOrigin(), + parentOrigin: '', + parentOriginIsOpaque: false, windowSessionToken: '', windowSessionIssuedAtMs: 0 }; @@ -2064,7 +6109,10 @@ timerInterval: null, lastRange: null, currentHighlightNode: null, - keepToolbar: false + keepToolbar: false, + noteDragFrame: null, + noteListDragging: false, + noteSuppressClickUntil: 0 }; const testOverrides = { renderExplanations: null @@ -2233,6 +6281,10 @@ control.disabled = locked || state.readOnly; } }); + if (dom.resetBtn) dom.resetBtn.disabled = locked || state.readOnly; + document.querySelectorAll('#reading-note-drawer [data-note-outline-add], #reading-note-drawer [data-note-outline-toggle], #reading-note-drawer [data-note-outline-title], #reading-note-drawer [data-note-outline-delete], #reading-note-drawer [data-note-drag-handle], #reading-note-drawer [data-note-delete]').forEach((control) => { + if ('disabled' in control) control.disabled = locked; + }); disableDragInteractions(); } @@ -2269,20 +6321,15 @@ } function readReadingCandidateCodePreferences() { - try { - const raw = global.localStorage?.getItem(READING_CANDIDATE_CODE_PREF_KEY); - const parsed = raw ? JSON.parse(raw) : null; - const mode = parsed?.mode === 'custom' ? 'custom' : 'auto'; - const customCode = typeof parsed?.customCode === 'string' - ? parsed.customCode.replace(/\D/g, '').slice(0, 6) - : ''; - return { - mode, - customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' - }; - } catch (_) { - return { mode: 'auto', customCode: '' }; - } + return { ...readingCandidateCodeCache }; + } + + async function loadReadingCandidateCodePreferences() { + await global.AppData.ready; + const stored = await global.AppData.preferences.getCandidateCode(); + const mode = stored?.mode === 'custom' ? 'custom' : 'auto'; + const customCode = typeof stored?.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : ''; + readingCandidateCodeCache = { mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' }; } function resolveReadingCandidateCode() { @@ -2303,6 +6350,8 @@ const rawLimitSeconds = Number(state.suiteTimerLimitSeconds); if (Number.isFinite(rawLimitSeconds) && rawLimitSeconds > 0) { limitSeconds = Math.floor(rawLimitSeconds); + } else if (state.suiteSessionId && state.suiteTimerMode === 'countdown') { + limitSeconds = minutesToSeconds(60, 60); } else if (preferences.limitEnabled) { limitSeconds = minutesToSeconds(preferences.limitMinutes, 60); } else { @@ -2340,8 +6389,10 @@ } timer.classList.toggle('paused', !interaction.timerRunning && !hasEndlessCountdown); timer.classList.toggle('timer-expired', expired); - timer.dataset.timerMode = preferences.mode; - timer.dataset.expiryAction = preferences.expiryAction; + if (timer.dataset) { + timer.dataset.timerMode = preferences.mode; + timer.dataset.expiryAction = preferences.expiryAction; + } timer.style.opacity = (interaction.timerRunning || hasEndlessCountdown) ? '1' : '0.5'; var _warnRemaining = !hasEndlessCountdown && (preferences.mode === 'countdown' || (Number.isFinite(Number(limitSeconds)) && Number(limitSeconds) > 0)) @@ -2469,6 +6520,10 @@ function updateSelectionToolbar() { const toolbar = document.getElementById('selbar'); if (!toolbar) return; + if (!canEditReadingNotes()) { + toolbar.style.display = 'none'; + return; + } const selection = global.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { if (!interaction.keepToolbar && !interaction.currentHighlightNode) { @@ -2530,6 +6585,10 @@ function applySelectionHighlight(kind = 'highlight') { const toolbar = document.getElementById('selbar'); + if (!canEditReadingNotes()) { + if (toolbar) toolbar.style.display = 'none'; + return; + } const selection = global.getSelection(); if (!interaction.lastRange || interaction.lastRange.collapsed || interaction.currentHighlightNode) { return; @@ -2548,11 +6607,19 @@ if (toolbar) toolbar.style.display = 'none'; interaction.lastRange = null; interaction.currentHighlightNode = null; - syncSimulationDraftSnapshot('highlight'); + if (kind === 'note') { + const note = ensureNoteForHighlight(span, normalizeNoteText(span.textContent), { sync: false }); + if (note) openNoteEditor(note.id, { anchorNode: span, focusBody: true }); + } + syncReadingAnnotation('highlight'); } function removeSelectionHighlight() { const toolbar = document.getElementById('selbar'); + if (!canEditReadingNotes()) { + if (toolbar) toolbar.style.display = 'none'; + return; + } const selection = global.getSelection(); let target = interaction.currentHighlightNode; if (!target && interaction.lastRange) { @@ -2561,6 +6628,7 @@ ? ancestor.parentElement?.closest('.hl') : ancestor.closest?.('.hl'); } + const removedNoteId = target instanceof HTMLElement ? String(target.dataset.noteId || '') : ''; if (target && target.parentNode) { const parent = target.parentNode; while (target.firstChild) { @@ -2573,7 +6641,8 @@ if (toolbar) toolbar.style.display = 'none'; interaction.lastRange = null; interaction.currentHighlightNode = null; - syncSimulationDraftSnapshot('unhighlight'); + if (removedNoteId) deleteNote(removedNoteId, { sync: false }); + syncReadingAnnotation('unhighlight'); } function attachSelectionHighlightToolbar() { @@ -2590,13 +6659,13 @@ }); document.getElementById('btnHL')?.addEventListener('click', () => applySelectionHighlight('highlight')); document.getElementById('btnNote')?.addEventListener('click', () => { + if (!canEditReadingNotes()) return; let targetNode = interaction.currentHighlightNode; let text = ''; if (targetNode) { if (targetNode.dataset.hlType !== 'note') { targetNode.dataset.hlType = 'note'; - syncSimulationDraftSnapshot('highlight'); } text = (targetNode.textContent || '').trim(); } else if (interaction.lastRange && !interaction.lastRange.collapsed) { @@ -2620,18 +6689,10 @@ interaction.lastRange = null; interaction.currentHighlightNode = null; - if (text) { - const noteArea = document.querySelector('#notes-panel textarea'); - if (noteArea) { - noteArea.value += (noteArea.value ? '\n\n' : '') + '> ' + text + '\n'; - noteArea.scrollTop = noteArea.scrollHeight; - noteArea.focus(); - } + if (targetNode && text) { + const note = ensureNoteForHighlight(targetNode, text); closeFloatingPanels(); - const notesPanel = document.getElementById('notes-panel'); - const overlay = document.querySelector('.overlay'); - if (notesPanel) notesPanel.style.display = 'flex'; - if (overlay) overlay.style.display = 'block'; + if (note) openNoteEditor(note.id, { anchorNode: targetNode, focusBody: true }); } }); document.getElementById('btnUH')?.addEventListener('click', removeSelectionHighlight); @@ -2919,6 +6980,9 @@ } function getNotesText() { + if (state.noteUiInitialized) { + return formatNotesForLegacyText(state.notes); + } const noteArea = document.querySelector('#notes-panel textarea'); return noteArea ? String(noteArea.value || '') : ''; } @@ -2930,11 +6994,164 @@ } } + function generateNoteId() { + return `note_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + } + + function generateNoteOutlineId() { + return `outline_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + } + + function normalizeNoteText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function buildDefaultNoteTitle(quote = '') { + const text = normalizeNoteText(quote); + if (!text) return 'Untitled note'; + return text.length > 36 ? `${text.slice(0, 36)}...` : text; + } + + function compareNoteOrder(a, b) { + const orderA = Number.isFinite(Number(a?.order)) ? Number(a.order) : 0; + const orderB = Number.isFinite(Number(b?.order)) ? Number(b.order) : 0; + if (orderA !== orderB) return orderA - orderB; + return Number(a?.createdAt || 0) - Number(b?.createdAt || 0); + } + + function normalizeNotes(rawNotes) { + const seen = new Set(); + return (Array.isArray(rawNotes) ? rawNotes : []).map((entry, index) => { + if (!entry || typeof entry !== 'object') return null; + let id = entry.id != null ? String(entry.id).trim() : ''; + if (!id || seen.has(id)) id = generateNoteId(); + seen.add(id); + const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now(); + return { + id, + title: entry.title != null ? String(entry.title) : '', + body: entry.body != null ? String(entry.body) : '', + quote: entry.quote != null ? String(entry.quote) : '', + outlineId: entry.outlineId != null ? String(entry.outlineId).trim() : '', + order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index, + createdAt, + updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt + }; + }).filter(Boolean); + } + + function normalizeNoteOutlines(rawOutlines) { + const seen = new Set(); + return (Array.isArray(rawOutlines) ? rawOutlines : []).map((entry, index) => { + if (!entry || typeof entry !== 'object') return null; + let id = entry.id != null ? String(entry.id).trim() : ''; + if (!id || seen.has(id)) id = generateNoteOutlineId(); + seen.add(id); + const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now(); + return { + id, + title: String(entry.title || '').trim() || 'New outline', + order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index, + collapsed: Boolean(entry.collapsed), + createdAt, + updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt + }; + }).filter(Boolean).sort(compareNoteOrder); + } + + function sanitizeNotesWithOutlines(rawNotes, rawOutlines) { + const noteOutlines = normalizeNoteOutlines(rawOutlines); + const validIds = new Set(noteOutlines.map((outline) => outline.id)); + const notes = normalizeNotes(rawNotes).map((note, index) => ({ + ...note, + outlineId: validIds.has(note.outlineId) ? note.outlineId : '', + order: Number.isFinite(Number(note.order)) ? Number(note.order) : index + })); + return { notes, noteOutlines }; + } + + function collectNotes() { + return normalizeNotes(state.notes); + } + + function collectNoteOutlines() { + return normalizeNoteOutlines(state.noteOutlines); + } + + function getNoteById(noteId) { + const id = String(noteId || '').trim(); + return id ? state.notes.find((note) => note && note.id === id) || null : null; + } + + function getValidNoteOutlineId(outlineId) { + const id = String(outlineId || '').trim(); + return id && state.noteOutlines.some((outline) => outline.id === id) ? id : ''; + } + + function sortNotesForDrawer(notes = state.notes) { + return (Array.isArray(notes) ? notes : []).filter(Boolean).slice().sort(compareNoteOrder); + } + + function getNextNoteOrder(outlineId = '') { + const id = getValidNoteOutlineId(outlineId); + const matching = state.notes.filter((note) => (note?.outlineId || '') === id); + return matching.length + ? Math.max(...matching.map((note) => Number.isFinite(Number(note.order)) ? Number(note.order) : 0)) + 1 + : 0; + } + + function formatNotesForLegacyText(notes = state.notes) { + return normalizeNotes(notes).map((note) => { + const parts = [`# ${String(note.title || '').trim() || 'Untitled note'}`]; + if (note.quote) parts.push(`> ${normalizeNoteText(note.quote)}`); + if (note.body) parts.push(note.body); + return parts.join('\n'); + }).join('\n\n'); + } + + function syncNotesToLegacyText() { + setNotesText(formatNotesForLegacyText(state.notes)); + } + + function normalizeMarkedQuestions(rawQuestions) { + const seen = new Set(); + return (Array.isArray(rawQuestions) ? rawQuestions : []).map((entry) => ( + normalizeQuestionId(entry) || String(entry || '').trim().toLowerCase() + )).filter(Boolean).filter((entry) => { + if (seen.has(entry)) return false; + seen.add(entry); + return true; + }); + } + + function getCurrentMarkedQuestions() { + let marks = []; + let hostResolved = false; + if (typeof global.getPracticeMarkedQuestions === 'function') { + try { + const raw = global.getPracticeMarkedQuestions(); + hostResolved = raw != null; + marks = normalizeMarkedQuestions(raw); + } catch (_) { marks = []; } + } + // 只有当 host 没有 give 出结果时(函数不存在或抛错)才回退到缓存; + // 用户清空最后一个标记时 host 会返回 [],这是有效空集,不能再被 state.markedQuestions 复活, + // 否则清空无法持久,并会在后续 draft/annotation sync 中重新写入旧标记。 + if (!hostResolved && !marks.length) { + marks = normalizeMarkedQuestions(state.markedQuestions); + } + state.markedQuestions = marks.slice(); + return marks; + } + function buildEmptyDraft() { return { answers: {}, highlights: [], noteText: '', + notes: [], + noteOutlines: [], + markedQuestions: [], scrollY: 0, updatedAt: Date.now() }; @@ -2952,6 +7169,9 @@ noteText: typeof source.noteText === 'string' ? source.noteText : '', + notes: normalizeNotes(source.notes), + noteOutlines: normalizeNoteOutlines(source.noteOutlines), + markedQuestions: normalizeMarkedQuestions(source.markedQuestions), scrollY: Number.isFinite(Number(source.scrollY)) ? Number(source.scrollY) : 0, @@ -2980,7 +7200,7 @@ const mergedUpdatedAt = Number.isFinite(Number(next.updatedAt)) ? Number(next.updatedAt) : (Number.isFinite(Number(base.updatedAt)) ? Number(base.updatedAt) : Date.now()); - return Object.assign(buildEmptyDraft(), base, next, { + const merged = Object.assign(buildEmptyDraft(), base, next, { answers: next.answers && typeof next.answers === 'object' ? { ...next.answers } : { ...base.answers }, @@ -2990,11 +7210,22 @@ noteText: typeof next.noteText === 'string' ? next.noteText : base.noteText, + notes: Array.isArray(nextDraft?.notes) ? normalizeNotes(next.notes) : normalizeNotes(base.notes), + noteOutlines: Array.isArray(nextDraft?.noteOutlines) + ? normalizeNoteOutlines(next.noteOutlines) + : normalizeNoteOutlines(base.noteOutlines), + markedQuestions: Array.isArray(nextDraft?.markedQuestions) + ? normalizeMarkedQuestions(next.markedQuestions) + : normalizeMarkedQuestions(base.markedQuestions), scrollY: Number.isFinite(Number(next.scrollY)) ? Number(next.scrollY) : base.scrollY, updatedAt: mergedUpdatedAt }); + const sanitized = sanitizeNotesWithOutlines(merged.notes, merged.noteOutlines); + merged.notes = sanitized.notes; + merged.noteOutlines = sanitized.noteOutlines; + return merged; } function mergeSuiteDraftPayload(data = {}) { @@ -3093,6 +7324,9 @@ answers: collectAnswers(), highlights: collectHighlights(), noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), scrollY: global.scrollY || 0, updatedAt: Date.now() }); @@ -3256,7 +7490,6 @@ refreshDynamicQuestionEnhancements(); clearCurrentAnswers(); applyDraftToDom(slot.draft || buildEmptyDraft()); - setNotesText(slot.draft?.noteText || ''); syncSimulationCtxForActiveSlot(); syncInlineSuiteIdentity(); state.simulationMode = true; @@ -3267,25 +7500,886 @@ if (Number.isFinite(Number(slot.draft?.scrollY))) { global.scrollTo(0, Number(slot.draft.scrollY) || 0); } - if (!options.skipDraftSync) { - syncSimulationDraftSnapshot('activate'); + if (!options.skipDraftSync) { + syncSimulationDraftSnapshot('activate'); + } + if (!options.silent) { + postMessage('SIMULATION_ACTIVE_EXAM_CHANGE', { + examId: targetExamId, + currentIndex: state.suite.currentIndex, + suiteSequence: state.suite.sequence.map((entry) => ({ ...entry })) + }); + } + return true; + } + + async function ensureExplanationManifest() { + if (global.__READING_EXPLANATION_MANIFEST__) { + return global.__READING_EXPLANATION_MANIFEST__; + } + await loadScript('../reading-explanations/manifest.js'); + return global.__READING_EXPLANATION_MANIFEST__ || {}; + } + + function ensureReadingDisplayControlStyles() { + if (document.getElementById(READING_DISPLAY_CONTROL_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = READING_DISPLAY_CONTROL_STYLE_ID; + style.textContent = ` + .reading-display-toggle-group{display:inline-flex;align-items:center;gap:4px;padding:2px;border:1px solid #dbe4ef;border-radius:8px;background:#f8fafc} + .reading-display-toggle{border:0;border-radius:6px;min-width:30px;height:28px;padding:0 8px;cursor:pointer;color:#64748b;background:transparent;font-size:12px;font-weight:700} + .reading-display-toggle:hover{background:#eef2f7;color:#0f172a}.reading-display-toggle.is-on{background:#dbeafe;color:#1d4ed8} + body.hide-reading-locators .reading-locator-highlight{background:transparent!important;box-shadow:none!important;outline:none!important} + body.hide-reading-locators .reading-locator-overlap{text-decoration:none!important;outline:none!important} + body.hide-reading-locators .reading-passage-locator-target.is-review-jump-target{background:transparent!important;outline:none!important} + body.hide-reading-notes .hl[data-hl-type="note"],body.hide-reading-notes .hl[data-note-id]{background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important;pointer-events:none} + body.hide-reading-highlights .hl:not([data-hl-type="note"]):not([data-note-id]){background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important} + body.reading-question-nav-collapsed .practice-nav{display:none} + body.dark-mode .reading-display-toggle-group{background:#1e293b;border-color:#475569;color:#cbd5e1} + `; + document.head.appendChild(style); + } + + function saveReadingDisplayPreferences() { + global.AppData.preferences.setReadingDisplay({ + highlightVisibility: state.highlightVisibility, + questionNavCollapsed: state.questionNavCollapsed + }).catch((error) => console.warn('[ReadingDisplay] 保存失败:', error)); + } + + async function loadReadingDisplayPreferences() { + try { + const saved = await global.AppData.preferences.getReadingDisplay(); + if (saved?.highlightVisibility) { + state.highlightVisibility = { + locators: saved.highlightVisibility.locators !== false, + notes: saved.highlightVisibility.notes !== false, + highlights: saved.highlightVisibility.highlights !== false + }; + } + state.questionNavCollapsed = Boolean(saved?.questionNavCollapsed); + } catch (_) { /* Ignore invalid preference payloads. */ } + applyReadingDisplayState(); + } + + function applyReadingDisplayState() { + if (!document.body) return; + document.body.classList.toggle('hide-reading-locators', state.highlightVisibility.locators === false); + document.body.classList.toggle('hide-reading-notes', state.highlightVisibility.notes === false); + document.body.classList.toggle('hide-reading-highlights', state.highlightVisibility.highlights === false); + document.body.classList.toggle('reading-question-nav-collapsed', state.questionNavCollapsed); + document.querySelectorAll('[data-highlight-toggle]').forEach((button) => { + const key = button.getAttribute('data-highlight-toggle'); + const enabled = state.highlightVisibility[key] !== false; + button.classList.toggle('is-on', enabled); + button.setAttribute('aria-pressed', enabled ? 'true' : 'false'); + }); + const navToggle = document.getElementById('reading-question-nav-toggle'); + if (navToggle) { + const collapsed = state.questionNavCollapsed; + // is-on means the question card bar is currently visible. + navToggle.classList.toggle('is-on', !collapsed); + navToggle.setAttribute('aria-pressed', collapsed ? 'false' : 'true'); + navToggle.title = collapsed ? '显示题卡' : '隐藏题卡'; + navToggle.textContent = 'Q'; + } + } + + function ensureReadingDisplayControls() { + ensureReadingDisplayControlStyles(); + // Remove the legacy floating bottom-right nav toggle if an older session left one behind. + document.querySelectorAll('body > #reading-question-nav-toggle, body > .reading-question-nav-toggle').forEach((node) => { + if (node.closest?.('.reading-display-toggle-group')) return; + node.remove(); + }); + const headerRight = document.querySelector('.header-right'); + if (headerRight && !document.getElementById('reading-display-toggle-group')) { + const group = document.createElement('div'); + group.id = 'reading-display-toggle-group'; + group.className = 'reading-display-toggle-group'; + group.setAttribute('aria-label', '阅读显示控制'); + group.innerHTML = [ + '', + '', + '', + '' + ].join(''); + const settingsButton = document.getElementById('settings-btn'); + headerRight.insertBefore(group, settingsButton?.parentNode === headerRight ? settingsButton : null); + group.addEventListener('click', (event) => { + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target) return; + const navButton = target.closest('[data-question-nav-toggle]'); + if (navButton) { + state.questionNavCollapsed = !state.questionNavCollapsed; + applyReadingDisplayState(); + saveReadingDisplayPreferences(); + return; + } + const button = target.closest('[data-highlight-toggle]'); + if (!button) return; + const key = button.getAttribute('data-highlight-toggle'); + if (!Object.prototype.hasOwnProperty.call(state.highlightVisibility, key)) return; + state.highlightVisibility[key] = state.highlightVisibility[key] === false; + applyReadingDisplayState(); + saveReadingDisplayPreferences(); + }); + } else { + // If the group already exists without the nav toggle (hot reload / partial DOM), attach it. + const group = document.getElementById('reading-display-toggle-group'); + if (group && !document.getElementById('reading-question-nav-toggle')) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'reading-display-toggle'; + button.id = 'reading-question-nav-toggle'; + button.setAttribute('data-question-nav-toggle', ''); + button.title = '隐藏题卡'; + button.setAttribute('aria-pressed', 'true'); + button.textContent = 'Q'; + button.addEventListener('click', (event) => { + event.stopPropagation(); + state.questionNavCollapsed = !state.questionNavCollapsed; + applyReadingDisplayState(); + saveReadingDisplayPreferences(); + }); + group.appendChild(button); + } + } + applyReadingDisplayState(); + } + + function ensureReadingNoteStyles() { + if (document.getElementById(READING_NOTE_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = READING_NOTE_STYLE_ID; + style.textContent = ` + .hl[data-note-id]{position:relative;cursor:pointer;background:rgba(191,219,254,.78)!important;box-shadow:inset 0 -.52em rgba(147,197,253,.34)} + .hl[data-note-id].reading-note-flash{outline:2px solid #60a5fa;outline-offset:2px}.reading-notes-btn{position:relative} + .reading-note-count{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:99px;background:#16a34a;color:#fff;font-size:10px;line-height:16px;text-align:center;font-weight:700;display:none} + #reading-note-drawer{position:fixed;inset:0 0 0 auto;width:min(360px,92vw);background:#fff;border-left:1px solid #dbe4ef;box-shadow:-18px 0 36px rgba(15,23,42,.16);z-index:3600;transform:translateX(105%);transition:transform 180ms ease;display:flex;flex-direction:column} + #reading-note-drawer.open{transform:translateX(0)}.reading-note-drawer-head,.reading-note-editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid #e2e8f0} + .reading-note-drawer-title{display:flex;align-items:center;gap:8px}.reading-note-drawer-head h3,.reading-note-editor-head h3{margin:0;font-size:16px}.reading-note-list{padding:10px;overflow:auto;flex:1} + .reading-note-outline{border:1px solid #dbeafe;border-radius:8px;margin-bottom:10px;overflow:hidden;background:#f8fbff}.reading-note-outline-head{display:grid;grid-template-columns:30px 1fr 30px;align-items:center;padding:5px;background:#eff6ff}.reading-note-outline.collapsed .reading-note-outline-body{display:none} + .reading-note-outline-body,.reading-note-loose-list{min-height:26px;padding:4px 8px}.reading-note-row{display:grid;grid-template-columns:1fr 28px 30px;align-items:center;gap:4px;border-bottom:1px solid #edf2f7}.reading-note-row.dragging{opacity:.45}.reading-note-row.drag-over{box-shadow:inset 0 2px #2563eb} + .reading-note-open,.reading-note-outline-title{border:0;background:transparent;color:#0f172a;text-align:left;padding:9px 6px;border-radius:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}.reading-note-open:hover{background:#eff6ff;color:#1d4ed8} + .reading-note-close,.reading-note-delete,.reading-note-outline-toggle,.reading-note-outline-delete,.reading-note-drag-handle,.reading-note-outline-add{border:0;background:transparent;color:#64748b;cursor:pointer;width:30px;height:30px;border-radius:6px}.reading-note-outline-add{background:#eff6ff;color:#1d4ed8;font-size:18px}.reading-note-outline-title-input{min-width:0;border:1px solid #93c5fd;border-radius:5px;padding:6px} + #reading-note-editor{position:fixed;z-index:3700;width:min(620px,calc(100vw - 24px));height:min(520px,calc(100vh - 24px));min-width:320px;min-height:320px;background:#fff;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 22px 50px rgba(15,23,42,.22);display:none;flex-direction:column;overflow:hidden;resize:both} + .reading-note-editor-head{cursor:move;background:#f8fafc;user-select:none}.reading-note-editor-body{display:flex;flex-direction:column;gap:10px;padding:14px;flex:1;min-height:0}.reading-note-quote{margin:0;color:#475569;background:#eff6ff;border-left:3px solid #60a5fa;padding:8px 10px;max-height:74px;overflow:auto} + .reading-note-title,.reading-note-body{width:100%;border:1px solid #cbd5e1;border-radius:6px;padding:9px 10px;box-sizing:border-box}.reading-note-title{font-weight:700}.reading-note-body{min-height:190px;resize:vertical;flex:1} + body.dark-mode #reading-note-drawer,body.dark-mode #reading-note-editor{background:#1e293b;border-color:#475569;color:#e2e8f0}body.dark-mode .reading-note-open,body.dark-mode .reading-note-outline-title{color:#f8fafc} + @media(max-width:520px){#reading-note-editor{inset:12px!important;width:calc(100vw - 24px);height:calc(100vh - 24px);min-width:0;min-height:0;resize:none}} + `; + document.head.appendChild(style); + } + + function ensureReadingNotesButton() { + let button = document.getElementById('notes-drawer-btn'); + if (button) return button; + const headerRight = document.querySelector('.header-right'); + if (!headerRight) return null; + button = document.createElement('button'); + button.id = 'notes-drawer-btn'; + button.type = 'button'; + button.className = 'header-btn reading-notes-btn'; + button.title = 'Notes'; + button.innerHTML = 'Notes'; + headerRight.insertBefore(button, headerRight.firstChild); + button.addEventListener('click', (event) => { event.stopPropagation(); toggleNotesDrawer(); }); + return button; + } + + function ensureReadingNotesUi() { + ensureReadingNoteStyles(); + ensureReadingNotesButton(); + const legacyPanel = document.getElementById('notes-panel'); + const legacyButton = document.getElementById('note-btn'); + if (legacyPanel) { legacyPanel.style.display = 'none'; legacyPanel.setAttribute('aria-hidden', 'true'); } + if (legacyButton) { legacyButton.style.display = 'none'; legacyButton.setAttribute('aria-hidden', 'true'); } + let drawer = document.getElementById('reading-note-drawer'); + if (!drawer) { + drawer = document.createElement('aside'); + drawer.id = 'reading-note-drawer'; + drawer.setAttribute('aria-hidden', 'true'); + drawer.innerHTML = '

Notes

'; + document.body.appendChild(drawer); + drawer.addEventListener('click', handleNoteDrawerClick); + drawer.addEventListener('keydown', handleNoteDrawerKeydown); + drawer.addEventListener('focusout', handleNoteDrawerFocusOut); + drawer.addEventListener('dragstart', handleNoteDragStart); + drawer.addEventListener('dragover', handleNoteDragOver); + drawer.addEventListener('drop', handleNoteDrop); + drawer.addEventListener('dragend', clearNoteDragIndicators); + } + let editor = document.getElementById('reading-note-editor'); + if (!editor) { + editor = document.createElement('section'); + editor.id = 'reading-note-editor'; + editor.setAttribute('aria-hidden', 'true'); + editor.innerHTML = '

Note

'; + document.body.appendChild(editor); + editor.addEventListener('click', (event) => { if (event.target.closest?.('[data-note-editor-close]')) closeNoteEditor(); }); + editor.querySelector('[data-note-title]')?.addEventListener('input', saveActiveNoteFromEditor); + editor.querySelector('[data-note-body]')?.addEventListener('input', saveActiveNoteFromEditor); + editor.querySelector('[data-note-title]')?.addEventListener('change', flushActiveNoteFromEditor); + editor.querySelector('[data-note-body]')?.addEventListener('change', flushActiveNoteFromEditor); + attachNoteEditorDrag(editor); + } + if (!state.noteUiInitialized) { + state.noteUiInitialized = true; + document.addEventListener('click', handleNoteHighlightClick, true); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { closeNoteEditor(); closeNotesDrawer(); } + }); + } + syncNotesToLegacyText(); + renderNotesDrawer(); + refreshNoteHighlightAttributes(); + return drawer; + } + + function toggleNotesDrawer() { + const drawer = ensureReadingNotesUi(); + if (drawer?.classList.contains('open')) closeNotesDrawer(); + else openNotesDrawer(); + } + + function openNotesDrawer() { + const drawer = ensureReadingNotesUi(); + if (!drawer) return; + state.noteDrawerDirty = true; + drawer.classList.add('open'); + drawer.setAttribute('aria-hidden', 'false'); + renderNotesDrawer(); + } + + function closeNotesDrawer() { + const drawer = document.getElementById('reading-note-drawer'); + drawer?.classList.remove('open'); + drawer?.setAttribute('aria-hidden', 'true'); + } + + function renderNoteRow(note) { + const title = String(note.title || '').trim() || 'Untitled note'; + const editable = canEditReadingNotes(); + const disabled = editable ? '' : ' disabled'; + return `
`; + } + + function renderNotesDrawer() { + const count = state.notes.length; + const badge = document.querySelector('#notes-drawer-btn .reading-note-count'); + if (badge) { badge.textContent = String(count); badge.style.display = count ? 'block' : 'none'; } + const list = document.querySelector('#reading-note-drawer [data-note-list]'); + if (!list || !state.noteDrawerDirty) return; + const disabled = canEditReadingNotes() ? '' : ' disabled'; + const notesByOutline = new Map(); + sortNotesForDrawer().forEach((note) => { + const outlineId = getValidNoteOutlineId(note.outlineId); + const group = notesByOutline.get(outlineId) || []; + group.push(note); + notesByOutline.set(outlineId, group); + }); + const outlinesHtml = collectNoteOutlines().map((outline) => { + const notes = notesByOutline.get(outline.id) || []; + return `
${notes.map(renderNoteRow).join('')}
`; + }).join(''); + const loose = (notesByOutline.get('') || []).map(renderNoteRow).join(''); + list.innerHTML = count || state.noteOutlines.length + ? `${outlinesHtml}
${loose}
` + : '
No notes yet.
'; + const add = document.querySelector('#reading-note-drawer [data-note-outline-add]'); + if (add) add.disabled = !canEditReadingNotes(); + state.noteDrawerDirty = false; + } + + function handleNoteDrawerClick(event) { + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target) return; + if (target.closest('[data-note-drawer-close]')) return closeNotesDrawer(); + if (target.closest('[data-note-outline-add]')) return createNoteOutline(); + const toggle = target.closest('[data-note-outline-toggle]'); + if (toggle) return toggleNoteOutline(toggle.getAttribute('data-note-outline-toggle')); + const outlineDelete = target.closest('[data-note-outline-delete]'); + if (outlineDelete) return deleteNoteOutline(outlineDelete.getAttribute('data-note-outline-delete')); + const outlineTitle = target.closest('[data-note-outline-title]'); + if (outlineTitle) return startRenameNoteOutline(outlineTitle.getAttribute('data-note-outline-title')); + const noteDelete = target.closest('[data-note-delete]'); + if (noteDelete) return deleteNote(noteDelete.getAttribute('data-note-delete')); + const noteOpen = target.closest('[data-note-open]'); + if (noteOpen) { + const noteId = noteOpen.getAttribute('data-note-open'); + const anchor = findOrRestoreNoteHighlight(noteId); + if (anchor) scrollNoteHighlightIntoView(anchor); + openNoteEditor(noteId, { anchorNode: anchor }); + } + } + + function upsertNote(rawNote, options = {}) { + if (!canEditReadingNotes()) return null; + const normalized = normalizeNotes([rawNote])[0]; + if (!normalized) return null; + normalized.outlineId = getValidNoteOutlineId(normalized.outlineId); + const index = state.notes.findIndex((note) => note.id === normalized.id); + if (index >= 0) state.notes.splice(index, 1, { ...state.notes[index], ...normalized }); + else { + if (!Number.isFinite(Number(rawNote?.order))) normalized.order = getNextNoteOrder(normalized.outlineId); + state.notes.push(normalized); + } + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + syncNotesToLegacyText(); + if (options.forceUi !== false) { renderNotesDrawer(); refreshNoteHighlightAttributes(normalized.id); } + if (options.sync !== false) syncReadingAnnotation(options.reason || 'note'); + return getNoteById(normalized.id); + } + + function setNotes(rawNotes, rawOutlines = [], options = {}) { + const sanitized = sanitizeNotesWithOutlines(rawNotes, rawOutlines); + state.notes = sanitized.notes; + state.noteOutlines = sanitized.noteOutlines; + if (!state.notes.length && options.legacyText) { + const legacyText = String(options.legacyText || ''); + if (legacyText.trim()) { + state.notes = normalizeNotes([{ id: generateNoteId(), title: 'Notes', body: legacyText, quote: '' }]); + } + } + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + ensureReadingNotesUi(); + syncNotesToLegacyText(); + renderNotesDrawer(); + refreshNoteHighlightAttributes(); + restoreMissingNoteAnchors(); + } + + function createNoteOutline() { + if (!canEditReadingNotes()) return; + const now = Date.now(); + state.noteOutlines.push({ id: generateNoteOutlineId(), title: 'New outline', order: state.noteOutlines.length, collapsed: false, createdAt: now, updatedAt: now }); + state.noteDrawerDirty = true; + renderNotesDrawer(); + startRenameNoteOutline(state.noteOutlines[state.noteOutlines.length - 1].id); + syncReadingAnnotation('note-outline-add'); + } + + function getNoteOutlineById(id) { return state.noteOutlines.find((outline) => outline.id === String(id || '')) || null; } + + function toggleNoteOutline(id) { + if (!canEditReadingNotes()) return; + const outline = getNoteOutlineById(id); + if (!outline) return; + outline.collapsed = !outline.collapsed; + outline.updatedAt = Date.now(); + state.noteDrawerDirty = true; + renderNotesDrawer(); + syncReadingAnnotation('note-outline-toggle'); + } + + function deleteNoteOutline(id) { + if (!canEditReadingNotes()) return; + const outlineId = String(id || ''); + state.noteOutlines = state.noteOutlines.filter((outline) => outline.id !== outlineId); + state.notes.forEach((note) => { if (note.outlineId === outlineId) note.outlineId = ''; }); + state.noteDrawerDirty = true; + renderNotesDrawer(); + syncReadingAnnotation('note-outline-delete'); + } + + function startRenameNoteOutline(id) { + if (!canEditReadingNotes()) return; + const outline = getNoteOutlineById(id); + const button = document.querySelector(`[data-note-outline-title="${escapeSelector(id)}"]`); + if (!outline || !button) return; + const input = document.createElement('input'); + input.className = 'reading-note-outline-title-input'; + input.value = outline.title; + input.setAttribute('data-note-outline-title-input', outline.id); + button.replaceWith(input); + input.focus(); input.select(); + } + + function commitRenameNoteOutline(input, cancel = false) { + if (!(input instanceof HTMLInputElement) || input.dataset.committed === 'true') return; + if (!canEditReadingNotes() && !cancel) cancel = true; + input.dataset.committed = 'true'; + const outline = getNoteOutlineById(input.getAttribute('data-note-outline-title-input')); + if (outline && !cancel) { outline.title = String(input.value || '').trim() || 'New outline'; outline.updatedAt = Date.now(); } + state.noteDrawerDirty = true; + renderNotesDrawer(); + if (!cancel) syncReadingAnnotation('note-outline-rename'); + } + + function handleNoteDrawerKeydown(event) { + const input = event.target instanceof HTMLElement ? event.target.closest('[data-note-outline-title-input]') : null; + if (input) { + if (!canEditReadingNotes() && event.key !== 'Escape') return; + if (event.key === 'Enter') { event.preventDefault(); commitRenameNoteOutline(input); } + else if (event.key === 'Escape') { event.preventDefault(); commitRenameNoteOutline(input, true); } + return; + } + const handle = event.target instanceof HTMLElement ? event.target.closest('[data-note-drag-handle]') : null; + if (!handle || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return; + if (!canEditReadingNotes()) return; + event.preventDefault(); + const note = getNoteById(handle.getAttribute('data-note-drag-handle')); + if (!note) return; + if (event.key === 'ArrowLeft') note.outlineId = ''; + else if (event.key === 'ArrowRight' && state.noteOutlines[0]) note.outlineId = state.noteOutlines[0].id; + else { + const siblings = sortNotesForDrawer().filter((item) => item.outlineId === note.outlineId); + const index = siblings.findIndex((item) => item.id === note.id); + const targetIndex = event.key === 'ArrowUp' ? index - 1 : index + 1; + if (targetIndex >= 0 && targetIndex < siblings.length) { + const targetOrder = siblings[targetIndex].order; + siblings[targetIndex].order = note.order; + note.order = targetOrder; + } + } + note.updatedAt = Date.now(); + state.noteDrawerDirty = true; + renderNotesDrawer(); + syncReadingAnnotation('note-reorder'); + } + + function handleNoteDrawerFocusOut(event) { + const input = event.target instanceof HTMLInputElement ? event.target.closest('[data-note-outline-title-input]') : null; + if (input) commitRenameNoteOutline(input); + } + + let draggedNoteId = ''; + function handleNoteDragStart(event) { + if (!canEditReadingNotes()) return; + const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null; + if (!row) return; + draggedNoteId = row.getAttribute('data-note-row') || ''; + row.classList.add('dragging'); + event.dataTransfer?.setData('text/plain', draggedNoteId); + } + + function handleNoteDragOver(event) { + if (!canEditReadingNotes()) return; + const target = event.target instanceof HTMLElement ? event.target.closest('[data-note-row], [data-note-drop-list]') : null; + if (!target) return; + event.preventDefault(); + clearNoteDragIndicators(); + document.querySelector(`[data-note-row="${escapeSelector(draggedNoteId)}"]`)?.classList.add('dragging'); + target.classList.add('drag-over'); + } + + function handleNoteDrop(event) { + if (!canEditReadingNotes()) return clearNoteDragIndicators(); + event.preventDefault(); + const note = getNoteById(draggedNoteId || event.dataTransfer?.getData('text/plain')); + const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null; + const list = event.target instanceof HTMLElement ? event.target.closest('[data-note-drop-list]') : null; + if (!note || (!row && !list)) return clearNoteDragIndicators(); + const outlineId = getValidNoteOutlineId((list || row.closest('[data-note-drop-list]'))?.getAttribute('data-note-drop-list')); + const siblings = sortNotesForDrawer().filter((item) => item.id !== note.id && (item.outlineId || '') === outlineId); + const index = row ? Math.max(0, siblings.findIndex((item) => item.id === row.getAttribute('data-note-row'))) : siblings.length; + siblings.splice(index < 0 ? siblings.length : index, 0, note); + siblings.forEach((item, order) => { item.outlineId = outlineId; item.order = order; item.updatedAt = Date.now(); }); + state.noteDrawerDirty = true; + clearNoteDragIndicators(); + renderNotesDrawer(); + syncReadingAnnotation('note-reorder'); + } + + function clearNoteDragIndicators() { + document.querySelectorAll('.reading-note-row.dragging,.reading-note-row.drag-over,[data-note-drop-list].drag-over').forEach((node) => node.classList.remove('dragging', 'drag-over')); + draggedNoteId = ''; + } + + function clampNoteEditorPosition(left, top) { + const editor = document.getElementById('reading-note-editor'); + const margin = 12; + const width = editor?.offsetWidth || 430; + const height = editor?.offsetHeight || 330; + return { + left: Math.min(Math.max(margin, left), Math.max(margin, global.innerWidth - width - margin)), + top: Math.min(Math.max(margin, top), Math.max(margin, global.innerHeight - height - margin)) + }; + } + + function positionNoteEditor(anchorNode = null) { + const editor = document.getElementById('reading-note-editor'); + if (!editor) return; + let left = Number(state.noteEditorPosition?.left); + let top = Number(state.noteEditorPosition?.top); + if (!Number.isFinite(left) || !Number.isFinite(top)) { + const rect = anchorNode?.getBoundingClientRect?.(); + left = rect ? rect.left + Math.min(24, rect.width / 2) : (global.innerWidth - (editor.offsetWidth || 430)) / 2; + top = rect ? rect.bottom + 10 : (global.innerHeight - (editor.offsetHeight || 330)) / 2; + } + const position = clampNoteEditorPosition(left, top); + editor.style.left = `${Math.round(position.left)}px`; + editor.style.top = `${Math.round(position.top)}px`; + state.noteEditorPosition = position; + } + + function canEditReadingNotes() { + if (state.timerLocked) return false; + const activePracticeCanEdit = Boolean( + !state.readOnly + && !state.memorizeMode + && !state.submitted + ); + const submittedRecordCanEdit = Boolean( + state.submitted + && state.submittedRecordId + && !state.memorizeMode + ); + return Boolean(state.reviewMode || activePracticeCanEdit || submittedRecordCanEdit); + } + + function openNoteEditor(noteId, options = {}) { + ensureReadingNotesUi(); + if (state.activeNoteId && state.activeNoteId !== noteId) flushActiveNoteFromEditor(); + const note = getNoteById(noteId); + if (!note) return; + state.activeNoteId = note.id; + const editor = document.getElementById('reading-note-editor'); + const title = editor?.querySelector('[data-note-title]'); + const body = editor?.querySelector('[data-note-body]'); + const quote = editor?.querySelector('[data-note-quote]'); + if (!editor) return; + const canEditNotes = canEditReadingNotes(); + if (title) { title.value = note.title || ''; title.disabled = !canEditNotes; } + if (body) { body.value = note.body || ''; body.disabled = !canEditNotes; } + if (quote) { quote.textContent = note.quote || ''; quote.style.display = note.quote ? '' : 'none'; } + editor.style.display = 'flex'; + editor.setAttribute('aria-hidden', 'false'); + global.requestAnimationFrame(() => { + positionNoteEditor(options.anchorNode || findNoteHighlight(note.id)); + (options.focusBody ? body : title)?.focus(); + }); + } + + function closeNoteEditor() { + flushActiveNoteFromEditor(); + const editor = document.getElementById('reading-note-editor'); + if (editor) { editor.style.display = 'none'; editor.setAttribute('aria-hidden', 'true'); } + state.activeNoteId = ''; + } + + function attachNoteEditorDrag(editor) { + const handle = editor.querySelector('[data-note-drag-handle]'); + if (!handle) return; + let drag = null; + const move = (event) => { + if (!drag) return; + const next = clampNoteEditorPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y); + editor.style.left = `${Math.round(next.left)}px`; + editor.style.top = `${Math.round(next.top)}px`; + state.noteEditorPosition = next; + }; + const stop = () => { + drag = null; + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', stop); + document.removeEventListener('pointercancel', stop); + }; + handle.addEventListener('pointerdown', (event) => { + if (event.target.closest?.('button')) return; + const rect = editor.getBoundingClientRect(); + drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', stop); + document.addEventListener('pointercancel', stop); + event.preventDefault(); + }); + } + + function clearNoteEditorSaveTimer() { + if (state.noteEditorSaveTimer) global.clearTimeout(state.noteEditorSaveTimer); + state.noteEditorSaveTimer = null; + } + + function saveActiveNoteFromEditor() { + if (!canEditReadingNotes()) return; + const note = getNoteById(state.activeNoteId); + if (!note) return; + const editor = document.getElementById('reading-note-editor'); + const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim(); + const body = String(editor?.querySelector('[data-note-body]')?.value || ''); + if (title === note.title && body === note.body) return; + Object.assign(note, { title, body, updatedAt: Date.now() }); + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + state.noteEditorPendingSync = true; + syncNotesToLegacyText(); + clearNoteEditorSaveTimer(); + state.noteEditorSaveTimer = global.setTimeout(flushActiveNoteFromEditor, NOTE_EDITOR_SAVE_DEBOUNCE_MS); + } + + function flushActiveNoteFromEditor() { + if (!canEditReadingNotes()) return; + const note = getNoteById(state.activeNoteId); + if (!note) return; + const editor = document.getElementById('reading-note-editor'); + const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim(); + const body = String(editor?.querySelector('[data-note-body]')?.value || ''); + if (title === note.title && body === note.body && !state.noteEditorPendingSync) return; + clearNoteEditorSaveTimer(); + state.noteEditorPendingSync = false; + upsertNote({ ...note, title, body, updatedAt: Date.now() }, { forceUi: true, reason: 'note-edit' }); + } + + function createNoteAnchorSpan(note) { + const span = document.createElement('span'); + span.className = 'hl'; + span.dataset.hlType = 'note'; + span.dataset.noteId = note.id; + return span; + } + + function shouldSkipNoteAnchorTextNode(node) { + if (!node?.nodeValue?.trim()) return true; + const element = node.parentElement; + return Boolean(element?.closest?.('.hl') || getHighlightShared()?.isInsideExplanation?.(node)); + } + + function wrapNoteTextInRoot(root, note, quote) { + const nodes = getHighlightShared()?.getTextNodes?.(root) || []; + // 先统计整段里命中次数;saved highlight 缺失才会走到这条兜底路径,若同一引文 + // 多次出现,按“首次命中”绑定会静默定位到错误位置。这里要求全局唯一匹配才绑定, + // 否则放弃恢复该笔记的锚点,而不是盲目绑到第一个重复位置。 + let matchNode = null; + let matchIndex = -1; + let totalMatches = 0; + for (const node of nodes) { + if (shouldSkipNoteAnchorTextNode(node)) continue; + const value = String(node.nodeValue || ''); + let from = 0; + let idx = value.indexOf(quote, from); + while (idx >= 0) { + totalMatches += 1; + if (!matchNode) { + matchNode = node; + matchIndex = idx; + } + from = idx + quote.length; + idx = value.indexOf(quote, from); + } + } + if (totalMatches === 0 || totalMatches > 1 || !matchNode) { + return null; } - if (!options.silent) { - postMessage('SIMULATION_ACTIVE_EXAM_CHANGE', { - examId: targetExamId, - currentIndex: state.suite.currentIndex, - suiteSequence: state.suite.sequence.map((entry) => ({ ...entry })) - }); + const range = document.createRange(); + range.setStart(matchNode, matchIndex); range.setEnd(matchNode, matchIndex + quote.length); + const span = createNoteAnchorSpan(note); + try { range.surroundContents(span); return span; } catch (_) { return null; } + } + + function findRestorableNoteAnchor(note) { + const quote = normalizeNoteText(note?.quote); + if (!quote || quote.length < 2) return null; + // 唯一性的判定需要在整篇 passage 范围内完成;逐 root 绑定会让跨 root + // 的重复引文被误判为“当前 root 内唯一”。先聚合所有命中,再决定绑定。 + const roots = [dom.left, dom.groups].filter(Boolean); + let totalMatches = 0; + let matchRoot = null; + for (const root of roots) { + const nodes = getHighlightShared()?.getTextNodes?.(root) || []; + for (const node of nodes) { + if (shouldSkipNoteAnchorTextNode(node)) continue; + const value = String(node.nodeValue || ''); + let from = 0; + let idx = value.indexOf(quote, from); + while (idx >= 0) { + totalMatches += 1; + if (!matchRoot) matchRoot = root; + from = idx + quote.length; + idx = value.indexOf(quote, from); + } + } } - return true; + if (totalMatches !== 1 || !matchRoot) return null; + return wrapNoteTextInRoot(matchRoot, note, quote); } - async function ensureExplanationManifest() { - if (global.__READING_EXPLANATION_MANIFEST__) { - return global.__READING_EXPLANATION_MANIFEST__; + function restoreMissingNoteAnchors() { + let count = 0; + state.notes.forEach((note) => { + if (!findNoteHighlight(note.id) && findRestorableNoteAnchor(note)) count += 1; + }); + if (count) { state.noteHighlightMetaDirty = true; refreshNoteHighlightAttributes(); } + return count; + } + + function ensureNoteForHighlight(highlightNode, quote = '', options = {}) { + if (!(highlightNode instanceof HTMLElement)) return null; + let note = getNoteById(highlightNode.dataset.noteId); + if (!note && !canEditReadingNotes()) return null; + if (!note) { + const now = Date.now(); + note = upsertNote({ + id: highlightNode.dataset.noteId || generateNoteId(), + title: '', body: '', quote: quote || normalizeNoteText(highlightNode.textContent), + createdAt: now, updatedAt: now + }, { sync: false }); + } + if (note) { + highlightNode.dataset.noteId = note.id; + highlightNode.dataset.hlType = 'note'; + state.noteHighlightMetaDirty = true; + refreshNoteHighlightAttributes(note.id); + if (options.sync !== false) syncReadingAnnotation('note-anchor'); + } + return note; + } + + function ensureNoteAnchorsBeforeSnapshot() { + document.querySelectorAll('.hl[data-hl-type="note"]').forEach((node) => { + if (node instanceof HTMLElement && !node.dataset.noteId) { + ensureNoteForHighlight(node, normalizeNoteText(node.textContent), { sync: false }); + } + }); + } + + function findNoteHighlight(noteId) { + const id = String(noteId || '').trim(); + return id ? document.querySelector(`.hl[data-note-id="${escapeSelector(id)}"]`) : null; + } + + function findOrRestoreNoteHighlight(noteId) { + const existing = findNoteHighlight(noteId); + if (existing) return existing; + const note = getNoteById(noteId); + return note ? findRestorableNoteAnchor(note) : null; + } + + function scrollNoteHighlightIntoView(node) { + node?.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + node?.classList.add('reading-note-flash'); + global.setTimeout(() => node?.classList.remove('reading-note-flash'), 900); + } + + function deleteNote(noteId, options = {}) { + if (!canEditReadingNotes()) return; + const id = String(noteId || '').trim(); + if (!id) return; + state.notes = state.notes.filter((note) => note.id !== id); + document.querySelectorAll(`.hl[data-note-id="${escapeSelector(id)}"]`).forEach((node) => { + const parent = node.parentNode; + if (!parent) return; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + node.remove(); parent.normalize(); + }); + if (state.activeNoteId === id) { state.activeNoteId = ''; closeNoteEditor(); } + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + syncNotesToLegacyText(); + renderNotesDrawer(); + if (options.sync !== false) syncReadingAnnotation('note-delete'); + } + + function clearStructuredNotesForReset() { + if (!canEditReadingNotes()) return; + clearNoteEditorSaveTimer(); + state.noteEditorPendingSync = false; + state.activeNoteId = ''; + state.notes = []; + state.noteOutlines = []; + state.noteDrawerDirty = true; + state.noteHighlightMetaDirty = true; + document.querySelectorAll('.hl[data-note-id], .hl[data-hl-type="note"]').forEach((node) => { + const parent = node.parentNode; + if (!parent) return; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + node.remove(); + parent.normalize(); + }); + setNotesText(''); + const editor = document.getElementById('reading-note-editor'); + if (editor) { + editor.querySelectorAll('input, textarea').forEach((field) => { field.value = ''; }); + editor.style.display = 'none'; + editor.setAttribute('aria-hidden', 'true'); + } + closeNotesDrawer(); + renderNotesDrawer(); + } + + function refreshNoteHighlightAttributes(noteId = '') { + if (!state.noteHighlightMetaDirty && !noteId) return; + const selector = noteId ? `.hl[data-note-id="${escapeSelector(noteId)}"]` : '.hl[data-note-id]'; + document.querySelectorAll(selector).forEach((node) => { + if (!(node instanceof HTMLElement)) return; + const note = getNoteById(node.dataset.noteId); + const title = String(note?.title || '').trim() || buildDefaultNoteTitle(node.textContent); + node.dataset.hlType = 'note'; + node.title = `Note: ${title}`; + node.setAttribute('role', 'button'); + node.tabIndex = 0; + node.setAttribute('aria-label', `Open note: ${title}`); + }); + state.noteHighlightMetaDirty = false; + } + + function handleNoteHighlightClick(event) { + const highlight = event.target instanceof HTMLElement ? event.target.closest('.hl[data-note-id]') : null; + if (!highlight) return; + event.preventDefault(); event.stopPropagation(); + openNoteEditor(highlight.dataset.noteId, { anchorNode: highlight }); + } + + function syncReadingAnnotation(reason = 'note') { + if (!canEditReadingNotes()) return; + const isSuiteReviewAnnotation = Boolean( + state.simulationMode + && state.suiteReviewMode + && state.reviewMode + && state.suiteSessionId + ); + if (state.simulationMode && (!state.readOnly || isSuiteReviewAnnotation)) { + syncSimulationDraftSnapshot(reason); + return; + } + if (state.reviewMode) { + postMessage('READING_ANNOTATION_SYNC', { + examId: state.examId, + recordId: state.reviewRecordId || null, + reviewSessionId: state.reviewSessionId || null, + sessionId: state.sessionId || null, + windowSessionToken: state.windowSessionToken || null, + annotations: { + highlights: collectHighlights(), + noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), + scrollY: global.scrollY || 0 + }, + reason + }); + return; + } + // 单篇 final-submit 后(submitted=true,reviewMode=false),宿主在保存练习 + // 记录后通过 PRACTICE_RECORD_SAVED 回传 recordId。持有该 id 时,结果页笔记 + // 改动需要以 READING_ANNOTATION_SYNC 直接写回已存档的练习记录,而非走草稿 + // 同步(草稿在提交时已被清除,且 draft 分支在此状态下会被跳过)。 + if (state.submitted && state.submittedRecordId && !state.memorizeMode) { + postMessage('READING_ANNOTATION_SYNC', { + examId: state.examId, + recordId: state.submittedRecordId, + reviewSessionId: null, + sessionId: state.sessionId || null, + windowSessionToken: state.windowSessionToken || null, + annotations: { + highlights: collectHighlights(), + noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), + scrollY: global.scrollY || 0 + }, + reason + }); + return; + } + if (!state.readOnly && !state.submitted && !state.memorizeMode) { + syncReadingDraftSnapshot(reason); } - await loadScript('../reading-explanations/manifest.js'); - return global.__READING_EXPLANATION_MANIFEST__ || {}; } async function ensureExplanationDataset() { @@ -3405,6 +8499,11 @@ .reading-locator-highlight:hover { background: rgba(250, 204, 21, 0.62); } + .reading-locator-overlap { cursor:pointer; text-decoration:underline #dc2626 2px; text-underline-offset:3px; } + .reading-locator-highlight.is-review-jump-target,.reading-locator-overlap.is-review-jump-target { outline:2px solid rgba(37,99,235,.45); outline-offset:2px; } + .reading-locator-block { display:inline-block;width:1px;height:1em;overflow:hidden;opacity:0;pointer-events:none;vertical-align:baseline; } + .reading-passage-locator-target.is-review-jump-target { border-radius:4px;outline:2px solid rgba(37,99,235,.38);background:rgba(96,165,250,.12); } + .results-table .question-jump-btn { border:0;padding:0;background:transparent;color:#2563eb;font:inherit;font-weight:700;cursor:pointer;text-decoration:underline;text-underline-offset:2px; } `; document.head.appendChild(style); } @@ -3423,6 +8522,11 @@ return; } shared.unwrapMatchingHighlights(dom.left, LOCATOR_HIGHLIGHT_SELECTOR); + dom.left?.querySelectorAll('.reading-passage-locator-target').forEach((node) => node.classList.remove('reading-passage-locator-target', 'is-review-jump-target')); + dom.left?.querySelectorAll(LOCATOR_OVERLAP_SELECTOR).forEach((node) => { + node.classList.remove('reading-locator-overlap', 'is-review-jump-target'); + delete node.dataset.locatorOverlap; + }); } function getHighlightShared() { @@ -3725,17 +8829,14 @@ let draftsByExam = {}; let resultsByExam = {}; try { - const raw = global.sessionStorage?.getItem('ielts_sim_session'); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed) { - if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence; - if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam; - if (Array.isArray(parsed.results)) { - parsed.results.forEach(res => { - if (res && res.examId) resultsByExam[res.examId] = res; - }); - } + const parsed = global.AppData?.recovery?.windowSession?.get('simulation'); + if (parsed) { + if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence; + if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam; + if (Array.isArray(parsed.results)) { + parsed.results.forEach(res => { + if (res && res.examId) resultsByExam[res.examId] = res; + }); } } } catch (_) {} @@ -4430,7 +9531,7 @@ function attachMemorizeLocatorListeners() { document.addEventListener('click', (event) => { const target = event.target instanceof HTMLElement - ? event.target.closest('.reading-locator-highlight[data-question-id]') + ? event.target.closest('.reading-locator-highlight[data-question-id],.reading-locator-overlap[data-question-id],.reading-locator-block[data-question-id]') : null; if (!target) { return; @@ -4724,6 +9825,61 @@ return snippets; } + function buildLocatorSnippetVariants(text) { + const source = String(text || '').replace(/\s+/g, ' ').trim(); + if (!source) return []; + return Array.from(new Set([ + source, + source.replace(/[‘’]/g, "'").replace(/[“”]/g, '"'), + source.replace(/[‐‑‒–—―]/g, '-'), + source.replace(/\s+-\s+/g, ' — '), + source.replace(/\s+-\s+/g, ' – ') + ])).filter(Boolean); + } + + function normalizeLocatorComparableText(text) { + return String(text || '').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/[‐‑‒–—―]/g, '-').replace(/\s+/g, ' ').trim().toLowerCase(); + } + + function findPassageBlockForLocatorSnippet(snippet) { + if (!dom.left || !snippet) return null; + const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText); + return Array.from(dom.left.querySelectorAll('p, li, td, th, div')).filter((node) => { + if (node.closest(EXPLANATION_NODE_SELECTOR) || node.classList.contains('reading-locator-highlight')) return false; + if (node.tagName === 'DIV' && node.querySelector('p, li, td, th')) return false; + const text = normalizeLocatorComparableText(node.textContent); + return text.length >= 10 && variants.some((variant) => text.includes(variant)); + }).sort((a, b) => String(a.textContent || '').length - String(b.textContent || '').length)[0] || null; + } + + function markOverlappingLocatorHighlight(questionId, snippet) { + const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText); + const target = Array.from(dom.left?.querySelectorAll('.hl') || []).find((node) => { + const text = normalizeLocatorComparableText(node.textContent); + return text.length >= 12 && variants.some((variant) => text.includes(variant) || variant.includes(text)); + }); + if (!target) return null; + target.classList.add('reading-locator-overlap'); + target.dataset.questionId = questionId; + target.dataset.locatorOverlap = 'true'; + target.title = `Q${displayLabel(questionId)} 定位`; + return target; + } + + function createLocatorBlock(questionId, snippet) { + const target = findPassageBlockForLocatorSnippet(snippet); + if (!target) return null; + const existing = target.querySelector(`.reading-locator-block[data-question-id="${escapeSelector(questionId)}"]`); + if (existing) return existing; + target.classList.add('reading-passage-locator-target'); + const marker = document.createElement('span'); + marker.className = 'reading-locator-block'; + marker.dataset.questionId = questionId; + marker.setAttribute('aria-hidden', 'true'); + target.insertBefore(marker, target.firstChild); + return marker; + } + function buildMemorizeLocatorSnippets() { const snippetsByQuestionId = new Map(); const sections = Array.isArray(state.explanation?.questionExplanations) @@ -4767,7 +9923,7 @@ function applyMemorizeLocatorHighlights() { clearMemorizeLocatorHighlights(); - if (!state.memorizeMode || !dom.left) { + if ((!state.memorizeMode && !state.reviewMode && !state.submitted) || !dom.left) { return 0; } const shared = getHighlightShared(); @@ -4779,21 +9935,68 @@ let applied = 0; snippetsByQuestionId.forEach((snippets, questionId) => { snippets.slice(0, 4).forEach((snippet) => { - const matches = shared.wrapTextMatches(dom.left, snippet, { - className: 'reading-locator-highlight', - attrs: { - 'data-question-id': questionId, - title: `Q${displayLabel(questionId)} 定位` - }, - limit: 2, - skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block' - }); + let matches = []; + for (const variant of buildLocatorSnippetVariants(snippet)) { + if (matches.length) break; + matches = shared.wrapTextMatches(dom.left, variant, { + className: 'reading-locator-highlight', + attrs: { 'data-question-id': questionId, title: `Q${displayLabel(questionId)} 定位` }, + limit: 2, + skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block' + }); + } + if (!matches.length) { + const overlap = markOverlappingLocatorHighlight(questionId, snippet); + if (overlap) matches = [overlap]; + } + if (!matches.length) { + const marker = createLocatorBlock(questionId, snippet); + if (marker) matches = [marker]; + } applied += matches.length; }); }); return applied; } + function findLocatorAnchor(questionId) { + const normalized = normalizeQuestionId(questionId); + return Array.from(document.querySelectorAll('.reading-locator-highlight[data-question-id],.reading-locator-block[data-question-id],.reading-locator-overlap[data-question-id]')) + .find((node) => normalizeQuestionId(node.dataset.questionId) === normalized) || null; + } + + function applyLocatorHighlightsForQuestion(questionId) { + const normalized = normalizeQuestionId(questionId); + const snippets = buildMemorizeLocatorSnippets().get(normalized) || []; + if (!normalized || !dom.left) return 0; + const shared = getHighlightShared(); + for (const snippet of snippets) { + for (const variant of buildLocatorSnippetVariants(snippet)) { + const matches = shared?.wrapTextMatches?.(dom.left, variant, { + className: 'reading-locator-highlight', + attrs: { 'data-question-id': normalized, title: `Q${displayLabel(normalized)} 定位` }, + limit: 1, + skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block' + }) || []; + if (matches.length) return matches.length; + } + if (markOverlappingLocatorHighlight(normalized, snippet) || createLocatorBlock(normalized, snippet)) return 1; + } + return 0; + } + + function jumpToQuestionEvidence(questionId) { + if (!findLocatorAnchor(questionId)) applyLocatorHighlightsForQuestion(questionId); + const locator = findLocatorAnchor(questionId); + const target = locator || findQuestionAnchor(questionId); + if (!target) return false; + target.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); + const highlightTarget = locator?.classList.contains('reading-locator-block') ? locator.closest('.reading-passage-locator-target') : locator; + highlightTarget?.classList.add('is-review-jump-target'); + global.setTimeout(() => highlightTarget?.classList.remove('is-review-jump-target'), 1800); + return true; + } + async function renderMemorizeStudyLayer() { if (!state.memorizeMode) { return; @@ -4862,7 +10065,7 @@ if (!item) return null; const sourceDropzone = item.closest('.paragraph-dropzone, .match-dropzone, .drop-target-summary'); return { - value: item.dataset.heading || item.dataset.option || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(), + value: item.dataset.heading || item.dataset.option || item.dataset.key || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(), label: item.dataset.answerLabel || item.dataset.word || item.dataset.value || item.textContent.trim(), sourceDropzoneId: sourceDropzone?.dataset?.dropzoneId || '' }; @@ -5573,7 +10776,11 @@ const expectedToken = canonicalizeAnswerToken(answerKey[normalizedTargetId]); const assignedToken = assignments.get(normalizedTargetId) || ''; return { - displayUserAnswer: assignedToken || answers[normalizedTargetId] || '', + // Review rows for split-key multi-choice still show the full selected set + // so partial credit remains inspectable even though scoring is per expected token. + displayUserAnswer: selectedTokens.length + ? selectedTokens.slice() + : (assignedToken || answers[normalizedTargetId] || ''), expectedToken, isCorrect: Boolean(assignedToken && expectedToken && areAnswerTokensEquivalent(assignedToken, expectedToken)) }; @@ -5737,7 +10944,7 @@ const statusClass = entry.isCorrect ? 'result-correct' : (isPartial ? 'result-partial' : 'result-incorrect'); return ` - ${label} + ${userAnswer} ${correctAnswer || ''} ${status} @@ -5760,6 +10967,9 @@ `; dom.results.style.display = 'block'; + dom.results.querySelectorAll?.('[data-result-question-id]').forEach((button) => { + button.addEventListener('click', () => jumpToQuestionEvidence(button.dataset.resultQuestionId || '')); + }); } function escapeSelector(value) { @@ -5944,9 +11154,21 @@ const controls = document.querySelectorAll('input, textarea, select'); controls.forEach((control) => { if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement) { + // review、普通进行中练习、以及已回传 recordId 的结果页允许编辑笔记; + // 只读/计时锁定/背诵模式仍保持禁用,避免改动无法保存或破坏答题流程。 + const canEditNotes = canEditReadingNotes(); + if ( + canEditNotes + && typeof control.closest === 'function' + && control.closest('#reading-note-editor, #reading-note-drawer') + ) { + control.disabled = false; + return; + } control.disabled = state.readOnly || state.timerLocked; } }); + renderNotesDrawer(); syncPrimaryActionButtons(); refreshSimulationDraftSyncLifecycle(); enhanceReviewHighlights(); @@ -5967,6 +11189,8 @@ } function enterSubmittedReadOnlyState(reason = 'submit') { + clearSubmissionAckTimer(); + state.submissionStatus = 'submitted'; state.submitted = true; setReadOnlyMode(true, reason); disableDragInteractions(); @@ -5979,22 +11203,136 @@ syncPrimaryActionButtons(); } + function clearSubmissionAckTimer() { + if (state.submissionAckTimer) { + clearTimeout(state.submissionAckTimer); + state.submissionAckTimer = null; + } + } + + function createSubmissionId() { + try { + if (global.crypto && typeof global.crypto.randomUUID === 'function') { + return global.crypto.randomUUID(); + } + } catch (_) { + // Fall through to a session-bound identifier. + } + return [state.sessionId || 'session', state.examId || 'exam', Date.now(), Math.random().toString(36).slice(2)].join(':'); + } + + function restoreDraftSubmissionState(submissionId = '') { + if (state.submissionStatus === 'submitted') { + return false; + } + if (submissionId && state.submissionId && submissionId !== state.submissionId) { + return false; + } + clearSubmissionAckTimer(); + state.submissionStatus = 'draft'; + state.submitted = false; + syncPrimaryActionButtons(); + return true; + } + + function expirePendingSubmission(submissionId = '') { + if (state.submissionStatus !== 'submitting') { + return false; + } + return restoreDraftSubmissionState(submissionId || state.submissionId); + } + + function beginSubmission(messageType, payload, presentation = null) { + if (state.submissionStatus === 'submitting' || state.submissionStatus === 'submitted') { + return false; + } + if (!state.submissionId) { + state.submissionId = createSubmissionId(); + } + state.submissionStatus = 'submitting'; + state.pendingSubmissionPresentation = presentation; + syncPrimaryActionButtons(); + const delivered = postMessage(messageType, Object.assign({}, payload || {}, { + submissionId: state.submissionId + })); + if (!delivered) { + restoreDraftSubmissionState(state.submissionId); + return false; + } + clearSubmissionAckTimer(); + state.submissionAckTimer = setTimeout(() => { + expirePendingSubmission(state.submissionId); + }, SUBMIT_ACK_TIMEOUT_MS); + return true; + } + + function matchesPendingSubmission(data = {}) { + if (state.submissionStatus !== 'submitting') return false; + const submissionId = data && data.submissionId != null ? String(data.submissionId).trim() : ''; + const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : ''; + const examId = data && data.examId != null ? String(data.examId).trim() : ''; + const suiteSessionId = data && data.suiteSessionId != null ? String(data.suiteSessionId).trim() : ''; + if (!submissionId || submissionId !== state.submissionId) return false; + if (!sessionId || !state.sessionId || sessionId !== String(state.sessionId)) return false; + if (!examId || !state.examId || examId !== String(state.examId)) return false; + if (state.suiteSessionId && suiteSessionId !== String(state.suiteSessionId)) return false; + if (!state.suiteSessionId && suiteSessionId) return false; + return true; + } + + async function acceptSubmissionAcknowledgement(data = {}) { + if (!matchesPendingSubmission(data)) { + return false; + } + const presentation = state.pendingSubmissionPresentation; + clearSubmissionAckTimer(); + enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit'); + if (presentation && presentation.results) { + state.lastResults = presentation.results; + renderResults(presentation.results); + await renderExplanations(); + applyHighlights(Array.isArray(presentation.highlights) ? presentation.highlights : []); + refreshNoteHighlightAttributes(); + restoreMissingNoteAnchors(); + applyMemorizeLocatorHighlights(); + enhanceReviewHighlights(); + updateNavStatuses(presentation.results); + } + state.pendingSubmissionPresentation = null; + if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) { + stopSimulationDraftSync(); + clearSimulationDraftMirror(); + state.simulationDraftFingerprint = ''; + } + return true; + } + if (global.__IELTS_READING_PAGE_TEST_HOOKS__ === true) { global.__IELTS_UNIFIED_READING_PAGE_TEST__ = Object.assign( global.__IELTS_UNIFIED_READING_PAGE_TEST__ || {}, { buildReplayResults, mergeDraft, + normalizeNotes, + normalizeNoteOutlines, + syncReadingAnnotation, mergeSuiteDraftPayload, captureInlineSuiteDraftBeforeReinit, shouldIgnoreInlineSuiteEnvelope, shouldAcceptWindowSessionMessage, adoptWindowSessionMessage, + buildInitSignature, handleIncoming, initializeInlineSimulationSuite, buildResultsFromAnswers, renderTimer, handleSubmit, + beginSubmission, + acceptSubmissionAcknowledgement, + expirePendingSubmission, + restoreDraftSubmissionState, + stopReadingDraftSync, + stopSimulationDraftSync, getTestState() { return { examId: state.examId, @@ -6014,6 +11352,19 @@ currentIndex: state.suite?.currentIndex || 0, suiteInline: Boolean(state.suite?.inline), suiteTimerLimitSeconds: state.suiteTimerLimitSeconds, + reviewRecordId: state.reviewRecordId, + submittedRecordId: state.submittedRecordId, + submitted: state.submitted, + readOnly: state.readOnly, + submissionStatus: state.submissionStatus, + submissionId: state.submissionId, + parentOrigin: state.parentOrigin, + parentOriginIsOpaque: state.parentOriginIsOpaque, + expectedParentOrigin: state.expectedParentOrigin, + windowSessionToken: state.windowSessionToken, + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: normalizeMarkedQuestions(state.markedQuestions), suiteSequence: Array.isArray(state.suite?.sequence) ? state.suite.sequence.map((entry) => ({ ...entry })) : [], @@ -6140,7 +11491,7 @@ if (!state.readOnly || canResetSubmittedSingle) { setSubmitLabel(dom.submitBtn.dataset.defaultLabel || 'Submit'); } - dom.submitBtn.disabled = state.readOnly; + dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting'; } if (dom.resetBtn) { dom.resetBtn.style.display = ''; @@ -6161,7 +11512,7 @@ dom.submitBtn.style.display = ctx.isLast ? '' : 'none'; dom.submitBtn.setAttribute('type', 'button'); setSubmitLabel('Submit'); - dom.submitBtn.disabled = state.readOnly; + dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting'; } } @@ -6234,8 +11585,13 @@ } function resetToAnsweringPresentation() { + clearSubmissionAckTimer(); state.lastResults = null; state.submitted = false; + state.submissionStatus = 'draft'; + state.submissionId = ''; + state.pendingSubmissionPresentation = null; + state.submittedRecordId = ''; state.readOnly = false; state.timerLocked = false; state.timerExpired = false; @@ -6297,6 +11653,8 @@ syncPrimaryActionButtons(); } else { state.reviewMode = true; + // 进入 review 视图后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。 + state.submittedRecordId = ''; if (data.readOnly !== false) { enterSubmittedReadOnlyState('stationary-review'); } else { @@ -6307,6 +11665,7 @@ async function applyReplayRecord(data = {}) { const entry = data.entry && typeof data.entry === 'object' ? data.entry : data; + const replayData = entry.realData && typeof entry.realData === 'object' ? entry.realData : {}; const entryExamId = entry && entry.examId != null ? String(entry.examId).trim() : ''; const currentExamId = state.examId != null ? String(state.examId).trim() : ''; if (entryExamId && currentExamId && entryExamId !== currentExamId) { @@ -6319,7 +11678,10 @@ ? entry.markedQuestions : (Array.isArray(entry.metadata && entry.metadata.markedQuestions) ? entry.metadata.markedQuestions - : [])); + : (Array.isArray(replayData.markedQuestions) ? replayData.markedQuestions : []))); + state.reviewRecordId = String(data.recordId || entry.id || '').trim(); + // 进入 review 回放后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。 + state.submittedRecordId = ''; if (data.reviewSessionId) { state.reviewSessionId = data.reviewSessionId; } @@ -6329,8 +11691,16 @@ state.reviewMode = true; state.reviewViewMode = 'review'; applyReplayAnswersToDom(replayResults.answers || {}); - const replayHighlights = Array.isArray(entry.highlights) ? entry.highlights : []; + const replayHighlights = Array.isArray(entry.highlights) + ? entry.highlights + : (Array.isArray(replayData.highlights) ? replayData.highlights : []); applyHighlights(replayHighlights); + setNotes( + Array.isArray(entry.notes) ? entry.notes : replayData.notes, + Array.isArray(entry.noteOutlines) ? entry.noteOutlines : replayData.noteOutlines, + { legacyText: typeof entry.noteText === 'string' ? entry.noteText : replayData.noteText } + ); + state.markedQuestions = normalizeMarkedQuestions(replayMarks); enhanceReviewHighlights(); if (Number.isFinite(Number(entry.scrollY))) { global.scrollTo(0, Number(entry.scrollY)); @@ -6339,6 +11709,9 @@ renderResults(replayResults); await renderExplanations(); applyHighlights(replayHighlights); + refreshNoteHighlightAttributes(); + restoreMissingNoteAnchors(); + applyMemorizeLocatorHighlights(); enhanceReviewHighlights(); updateNavStatuses(replayResults); if (data.readOnly !== false) { @@ -6437,9 +11810,6 @@ function adoptWindowSessionMessage(data = {}, sourceWindow = null) { const incomingToken = normalizeWindowSessionToken(data && data.windowSessionToken); const incomingIssuedAtMs = readMessageIssuedAtMs(data); - if (sourceWindow) { - state.parentWindow = sourceWindow; - } if (incomingToken) { state.windowSessionToken = incomingToken; } @@ -6450,25 +11820,77 @@ } } - function postMessage(type, payload) { - const envelope = buildEnvelope(type, payload); - const candidates = [global.opener, state.parentWindow, global.parent]; - const visited = new Set(); - for (let index = 0; index < candidates.length; index += 1) { - const target = candidates[index]; - if (!target || target === global || visited.has(target)) { - continue; + function acceptHostInitMessage(event, envelope, data = {}) { + if (!state.parentWindow || !event || event.source !== state.parentWindow) return false; + if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false; + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + const declaredOrigin = typeof data.parentOrigin === 'string' ? data.parentOrigin : ''; + const incomingToken = normalizeWindowSessionToken(data.windowSessionToken); + if (!incomingToken) return false; + // "file://" is not a usable postMessage target/origin pin. Treat it the same + // as an unbound referrer so file:// hosts can bind via opaque "null". + const expectedParentOrigin = state.expectedParentOrigin + && state.expectedParentOrigin !== 'file://' + && !String(state.expectedParentOrigin).startsWith('file:') + ? state.expectedParentOrigin + : ''; + if (expectedParentOrigin) { + if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) { + return false; } - visited.add(target); - try { - target.postMessage(envelope, '*'); - state.parentWindow = target; - return true; - } catch (_) { - // try next candidate + state.parentOrigin = expectedParentOrigin; + state.parentOriginIsOpaque = false; + } else if (global.location.protocol === 'file:') { + // File pages can report either opaque "null" or "file://" for iframe + // messages across Chromium platforms; never accept a web origin here. + const trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://') + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + if (!trustedFileOrigin) { + return false; + } + state.parentOrigin = 'null'; + state.parentOriginIsOpaque = true; + } else { + const trustedWebOrigin = Boolean(incomingOrigin) + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + if (!trustedWebOrigin) { + return false; } + state.parentOrigin = incomingOrigin; + state.parentOriginIsOpaque = false; + } + return true; + } + + function isTrustedHostMessage(event, envelope, data = {}) { + if (!state.parentWindow || !event || event.source !== state.parentWindow) return false; + if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false; + const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; + if (state.parentOriginIsOpaque) { + if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') return false; + } else if (!state.parentOrigin || incomingOrigin !== state.parentOrigin) { + return false; + } + const expectedToken = normalizeWindowSessionToken(state.windowSessionToken); + const incomingToken = normalizeWindowSessionToken(data.windowSessionToken); + return Boolean(expectedToken && incomingToken && expectedToken === incomingToken); + } + + function postMessage(type, payload) { + const envelope = buildEnvelope(type, payload); + const target = state.parentWindow; + if (!target || target === global || typeof target.postMessage !== 'function') return false; + const targetOrigin = state.parentOrigin && state.parentOrigin !== 'null' + ? state.parentOrigin + : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : '')); + if (!targetOrigin) return false; + try { + return target.postMessage(envelope, targetOrigin) !== false; + } catch (_) { + return false; } - return false; } function stopInitLoop() { @@ -6510,7 +11932,8 @@ suiteTimerAnchorMs: Number.isFinite(Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs))) ? Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs)) : null, suiteTimerMode: data && typeof data.suiteTimerMode === 'string' ? data.suiteTimerMode.trim().toLowerCase() : '', suiteTimerLimitSeconds: parseOptionalNonNegativeInteger(data && data.suiteTimerLimitSeconds), - globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null + globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null, + draftFingerprint: buildDraftFingerprint(data && data.draft) }); } @@ -6536,6 +11959,10 @@ } function restartInitHandshake() { + clearSubmissionAckTimer(); + state.submissionStatus = 'draft'; + state.submissionId = ''; + state.pendingSubmissionPresentation = null; state.sessionId = null; state.sessionReadySent = false; state.lastInitSignature = ''; @@ -6587,13 +12014,13 @@ }, 500); } - function getSimulationDraftStorageKey() { + function getSimulationDraftSessionName() { const suiteSessionId = state.suiteSessionId ? String(state.suiteSessionId).trim() : ''; const examId = state.examId ? String(state.examId).trim() : ''; if (!suiteSessionId || !examId) { return ''; } - return `ielts_sim_draft::${suiteSessionId}::${examId}`; + return `simulation-draft:${suiteSessionId}:${examId}`; } function cloneDraftSafely(draft) { @@ -6607,6 +12034,9 @@ answers: draft.answers && typeof draft.answers === 'object' ? { ...draft.answers } : {}, highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [], noteText: typeof draft.noteText === 'string' ? draft.noteText : '', + notes: normalizeNotes(draft.notes), + noteOutlines: normalizeNoteOutlines(draft.noteOutlines), + markedQuestions: normalizeMarkedQuestions(draft.markedQuestions), scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0 }; } @@ -6617,6 +12047,11 @@ return ''; } try { + // updatedAt 每次调用都会刷新(Date.now()),若纳入指纹会让周期性比对永远不相等, + // 导致空闲时每 1.5s 都会重复 POST/持久化草稿。只用稳定内容计算指纹。 + if ('updatedAt' in draft) { + return JSON.stringify(Object.assign({}, draft, { updatedAt: null })); + } return JSON.stringify(draft); } catch (_) { return ''; @@ -6624,29 +12059,27 @@ } function persistSimulationDraftMirror(draft) { - const key = getSimulationDraftStorageKey(); - if (!key || !global.sessionStorage || !draft) { + const name = getSimulationDraftSessionName(); + if (!name || !global.AppData?.recovery?.windowSession || !draft) { return; } try { - global.sessionStorage.setItem(key, JSON.stringify({ + global.AppData.recovery.windowSession.save(name, { draft, updatedAt: Date.now() - })); + }); } catch (_) { // ignore sessionStorage failures in restricted environments } } function restoreSimulationDraftMirror() { - const key = getSimulationDraftStorageKey(); - if (!key || !global.sessionStorage) { + const name = getSimulationDraftSessionName(); + if (!name || !global.AppData?.recovery?.windowSession) { return null; } try { - const raw = global.sessionStorage.getItem(key); - if (!raw) return null; - const parsed = JSON.parse(raw); + const parsed = global.AppData.recovery.windowSession.get(name); if (!parsed || typeof parsed !== 'object') { return null; } @@ -6659,12 +12092,12 @@ } function clearSimulationDraftMirror() { - const key = getSimulationDraftStorageKey(); - if (!key || !global.sessionStorage) { + const name = getSimulationDraftSessionName(); + if (!name || !global.AppData?.recovery?.windowSession) { return; } try { - global.sessionStorage.removeItem(key); + global.AppData.recovery.windowSession.discard(name); } catch (_) { // ignore sessionStorage failures in restricted environments } @@ -6684,13 +12117,94 @@ answers, highlights: collectHighlights(), noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), scrollY: global.scrollY || 0, updatedAt }; } + function canSyncReadingDraft() { + return Boolean( + !state.simulationMode + && !state.reviewMode + && !state.readOnly + && !state.timerLocked + && !state.submitted + && !state.memorizeMode + && state.examId + && state.sessionId + && state.windowSessionToken + ); + } + + function syncReadingDraftSnapshot(reason = 'periodic') { + if (!canSyncReadingDraft()) { + return; + } + const draft = collectCurrentDraft(); + const fingerprint = buildDraftFingerprint(draft); + if (reason === 'periodic' && fingerprint && fingerprint === state.readingDraftFingerprint) { + return; + } + state.readingDraftFingerprint = fingerprint; + const mirroredDraft = cloneDraftSafely(draft); + if (!mirroredDraft) { + return; + } + postMessage('READING_DRAFT_SYNC', { + examId: state.examId, + sessionId: state.sessionId || null, + windowSessionToken: state.windowSessionToken || null, + draft: mirroredDraft, + draftUpdatedAt: Number.isFinite(Number(mirroredDraft.updatedAt)) ? Number(mirroredDraft.updatedAt) : Date.now(), + elapsed: getPageElapsedSeconds(), + reason + }); + } + + function stopReadingDraftSync() { + if (state.readingDraftSyncTimer) { + clearInterval(state.readingDraftSyncTimer); + state.readingDraftSyncTimer = null; + } + } + + function refreshReadingDraftSyncLifecycle() { + if (!canSyncReadingDraft()) { + stopReadingDraftSync(); + return; + } + if (!state.readingDraftSyncTimer) { + state.readingDraftSyncTimer = setInterval(() => { + syncReadingDraftSnapshot('periodic'); + }, READING_DRAFT_SYNC_MS); + } + syncReadingDraftSnapshot('activate'); + } + + function flushReadingDraftOnLifecycle(reason = 'pagehide') { + if (canSyncReadingDraft()) { + syncReadingDraftSnapshot(reason); + return; + } + // 草稿同步在 submitted/只读态被跳过;但单篇 final-submit 后若宿主已回传 + // submittedRecordId,结果页笔记改动仍需要落库——这里同步触发一次标注同步, + // 防止页面在 450ms 防抖触发前关闭/隐藏而丢失 READING_ANNOTATION_SYNC。 + if (state.submitted && state.submittedRecordId && !state.memorizeMode && !state.reviewMode) { + syncReadingAnnotation(reason); + } + } + function syncSimulationDraftSnapshot(reason = 'periodic') { - if (!state.simulationMode || state.readOnly || !state.suiteSessionId) { + if (state.timerLocked) return; + const isSuiteReviewAnnotation = Boolean( + state.suiteReviewMode + && state.reviewMode + && state.suiteSessionId + ); + if (!state.simulationMode || (state.readOnly && !isSuiteReviewAnnotation) || !state.suiteSessionId) { return; } const draft = state.suite?.inline @@ -6848,8 +12362,10 @@ if (Array.isArray(draft.highlights)) { applyHighlights(draft.highlights); } - if (typeof draft.noteText === 'string') { - setNotesText(draft.noteText); + setNotes(draft.notes, draft.noteOutlines, { legacyText: draft.noteText }); + state.markedQuestions = normalizeMarkedQuestions(draft.markedQuestions); + if (typeof global.setPracticeMarkedQuestions === 'function') { + try { global.setPracticeMarkedQuestions(state.markedQuestions); } catch (_) { /* ignore */ } } if (typeof draft.scrollY === 'number') { global.scrollTo(0, draft.scrollY); @@ -6866,6 +12382,7 @@ if (!shared) { return []; } + ensureNoteAnchorsBeforeSnapshot(); return shared.snapshotHighlights({ left: dom.left, groups: dom.groups @@ -6904,6 +12421,9 @@ answers: results.answers || {}, highlights: collectHighlights(), noteText: getNotesText(), + notes: collectNotes(), + noteOutlines: collectNoteOutlines(), + markedQuestions: getCurrentMarkedQuestions(), scrollY: global.scrollY || 0, elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0), timerSnapshot, @@ -6981,6 +12501,9 @@ questionTypePerformance: results.questionTypePerformance || {}, highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [], noteText: typeof draft.noteText === 'string' ? draft.noteText : '', + notes: normalizeNotes(draft.notes), + noteOutlines: normalizeNoteOutlines(draft.noteOutlines), + markedQuestions: normalizeMarkedQuestions(draft.markedQuestions), scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0, updatedAt: Number.isFinite(Number(draft.updatedAt)) ? Number(draft.updatedAt) : Date.now() }); @@ -7014,6 +12537,9 @@ scoreInfo, highlights: [], noteText: '', + notes: [], + noteOutlines: [], + markedQuestions: [], scrollY: global.scrollY || 0, elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0), timerSnapshot, @@ -7026,6 +12552,7 @@ input.checked = false; }); document.querySelectorAll('input[type="text"], textarea').forEach((input) => { + if (input.closest('#notes-panel, #reading-note-editor, #reading-note-drawer')) return; input.value = ''; }); document.querySelectorAll('select').forEach((select) => { @@ -7065,6 +12592,9 @@ answers: snapshot.answers || {}, highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [], noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '', + notes: normalizeNotes(snapshot.notes), + noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines), + markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions), scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0, updatedAt: Number.isFinite(Number(snapshot.updatedAt)) ? Number(snapshot.updatedAt) : Date.now() }, @@ -7073,6 +12603,9 @@ answers: snapshot.answers || {}, highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [], noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '', + notes: normalizeNotes(snapshot.notes), + noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines), + markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions), scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0, elapsed: Number.isFinite(Number(snapshot.elapsed)) ? Number(snapshot.elapsed) : getPageElapsedSeconds(), timerSnapshot: snapshot.timerSnapshot || getPracticeTimerSnapshot() @@ -7090,7 +12623,7 @@ handleExitClick(); return; } - if (state.readOnly) { + if (state.readOnly || state.submissionStatus !== 'draft') { return; } const submissionSnapshot = state.suite?.inline @@ -7105,15 +12638,12 @@ ? (Array.isArray(activeSlot?.draft?.highlights) ? activeSlot.draft.highlights : []) : (Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : []); const postedResults = submissionSnapshot.results || results; - state.lastResults = results; if (activeSlot) { activeSlot.lastResults = results; } - renderResults(results); - enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit'); const messageType = state.simulationMode ? 'SIMULATION_SUBMIT' : 'PRACTICE_COMPLETE'; const timing = resolvePracticeTiming(1, submissionSnapshot.timerSnapshot); - postMessage(messageType, Object.assign({ + beginSubmission(messageType, Object.assign({ duration: timing.duration, startTime: new Date(timing.startTimeMs).toISOString(), endTime: new Date(timing.endTimeMs).toISOString(), @@ -7133,25 +12663,22 @@ dataKey: state.dataKey, markedQuestions: (typeof global.getPracticeMarkedQuestions === 'function') ? global.getPracticeMarkedQuestions() - : [] + : normalizeMarkedQuestions(submissionSnapshot.markedQuestions) }, answers: submissionSnapshot.answers || {}, highlights: Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : [], noteText: typeof submissionSnapshot.noteText === 'string' ? submissionSnapshot.noteText : '', + notes: normalizeNotes(submissionSnapshot.notes), + noteOutlines: normalizeNoteOutlines(submissionSnapshot.noteOutlines), + markedQuestions: normalizeMarkedQuestions(submissionSnapshot.markedQuestions), scrollY: Number.isFinite(Number(submissionSnapshot.scrollY)) ? Number(submissionSnapshot.scrollY) : 0 }, state.suite?.inline ? { suiteSubmission: true, suiteEntries: Array.isArray(submissionSnapshot.suiteEntries) ? submissionSnapshot.suiteEntries : [] - } : {}, postedResults)); - await renderExplanations(); - applyHighlights(highlightSnapshot); - enhanceReviewHighlights(); - updateNavStatuses(results); - if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) { - stopSimulationDraftSync(); - clearSimulationDraftMirror(); - state.simulationDraftFingerprint = ''; - } + } : {}, postedResults), { + results, + highlights: highlightSnapshot + }); } function handleReset() { @@ -7162,6 +12689,7 @@ if (state.submitted && state.readOnlyReason === 'final-submit' && !state.suiteSessionId && !state.reviewMode) { resetToAnsweringPresentation(); clearCurrentAnswers(); + clearStructuredNotesForReset(); requestNormalPracticeRestart('retake-after-submit'); return; } @@ -7170,6 +12698,7 @@ } closeReviewHighlightDictionary(); clearCurrentAnswers(); + clearStructuredNotesForReset(); if (dom.results) { dom.results.style.display = 'none'; dom.results.innerHTML = ''; @@ -7187,7 +12716,7 @@ const opener = global.opener && !global.opener.closed ? global.opener : null; if (hasEndlessMarker && opener) { try { - opener.postMessage({ type: 'ENDLESS_USER_EXIT' }, '*'); + postMessage('ENDLESS_USER_EXIT', {}); if (typeof opener.stopEndlessPractice === 'function') { opener.stopEndlessPractice(); } else if (opener.AppActions && typeof opener.AppActions.stopEndlessPractice === 'function') { @@ -7288,6 +12817,9 @@ const data = payload.data || {}; const sourceWindow = event && typeof event === 'object' ? (event.source || null) : null; if (type === 'INIT_SESSION' || type === 'INIT_EXAM_SESSION') { + if (!acceptHostInitMessage(event, payload, data)) { + return; + } if (!shouldAcceptWindowSessionMessage(data, sourceWindow)) { return; } @@ -7314,6 +12846,12 @@ if (incomingExamId && !currentExamId) { state.examId = incomingExamId; } + if (data.sessionId && state.sessionId && String(data.sessionId) !== String(state.sessionId)) { + clearSubmissionAckTimer(); + state.submissionStatus = 'draft'; + state.submissionId = ''; + state.pendingSubmissionPresentation = null; + } if (data.sessionId) { state.sessionId = data.sessionId; } @@ -7391,14 +12929,28 @@ } if (data.reviewMode) { state.reviewMode = true; + // init 中的 review 模式同样不应沿用单篇 submitted 回传的 recordId。 + state.submittedRecordId = ''; if (data.readOnly !== false) { enterSubmittedReadOnlyState('stationary-review'); } else { setReadOnlyMode(false); } } + const singleDraft = !state.simulationMode + && !state.reviewMode + && data + && data.draft + && typeof data.draft === 'object' + ? data.draft + : null; + if (singleDraft) { + applyDraftToDom(singleDraft); + state.readingDraftFingerprint = buildDraftFingerprint(singleDraft); + } syncPrimaryActionButtons(); refreshSimulationDraftSyncLifecycle(); + refreshReadingDraftSyncLifecycle(); syncSuiteModeState(); stopInitLoop(); state.lastInitSignature = initSignature; @@ -7408,6 +12960,9 @@ sendSessionReady(); return; } + if (!isTrustedHostMessage(event, payload, data)) { + return; + } if (type === 'REPLAY_PRACTICE_RECORD') { const replaySignature = buildReplaySignature(data || {}); if (replaySignature && replaySignature === state.lastReplaySignature) { @@ -7421,6 +12976,40 @@ applyReviewContext(data || {}); return; } + if (type === 'PRACTICE_SUBMIT_ACK') { + await acceptSubmissionAcknowledgement(data || {}); + return; + } + if (type === 'PRACTICE_SUBMIT_FAILED') { + if (matchesPendingSubmission(data || {})) { + restoreDraftSubmissionState(String(data.submissionId || '')); + } + return; + } + if (type === 'VOCAB_HIGHLIGHT_SAVE_ACK' || type === 'VOCAB_HIGHLIGHT_SAVE_FAILED') { + const dictionary = getReviewHighlightDictionary(); + if (dictionary && typeof dictionary.handleSaveOutcome === 'function') { + dictionary.handleSaveOutcome(data || {}, type === 'VOCAB_HIGHLIGHT_SAVE_ACK'); + } + return; + } + if (type === 'PRACTICE_RECORD_SAVED') { + // 宿主在单篇阅读 final-submit 落库成功后回传已存档 recordId, + // 用于支持结果页笔记改动的持久化(syncReadingAnnotation 的 submitted 分支)。 + const payloadExamId = data && data.examId != null ? String(data.examId).trim() : ''; + const currentExamId = state.examId != null ? String(state.examId).trim() : ''; + if (payloadExamId && currentExamId && payloadExamId !== currentExamId && !state.suite?.inline) { + return; + } + const payloadSessionId = data && data.sessionId != null ? String(data.sessionId).trim() : ''; + const currentSessionId = state.sessionId != null ? String(state.sessionId).trim() : ''; + if (!payloadSessionId || !currentSessionId || payloadSessionId !== currentSessionId) { + return; + } + const recordId = data && data.recordId != null ? String(data.recordId).trim() : ''; + state.submittedRecordId = recordId; + return; + } if (type === 'SUITE_NAVIGATE' && data.url) { const targetSuiteSessionId = typeof data.suiteSessionId === 'string' ? data.suiteSessionId.trim() : ''; const currentSuiteSessionId = typeof state.suiteSessionId === 'string' ? state.suiteSessionId.trim() : ''; @@ -7577,6 +13166,30 @@ global.addEventListener('message', handleIncoming); } + function attachReadingDraftLifecycleHooks() { + const flush = (reason) => { + try { + // 先把编辑器里未提交的笔记立刻刷出:review 页面 flushReadingDraftOnLifecycle + // 会因 canSyncReadingDraft 直接 no-op,笔记只能靠 450ms 防抖提交,页面在 + // 防抖触发前关闭/隐藏就会丢失 READING_ANNOTATION_SYNC。这里同步触发一次, + // review 路径在同步里发出最新的 note,正常阅读路径则继续走 draft 快照。 + if (typeof flushActiveNoteFromEditor === 'function') { + flushActiveNoteFromEditor(); + } + flushReadingDraftOnLifecycle(reason); + } catch (_) { + // ignore draft flush failures during teardown + } + }; + global.addEventListener('pagehide', () => flush('pagehide')); + global.addEventListener('beforeunload', () => flush('beforeunload')); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flush('visibilitychange'); + } + }); + } + function attachPracticeTimerBridge() { global.addEventListener(PRACTICE_TIMER_EVENT, (event) => { const detail = event && event.detail && typeof event.detail === 'object' @@ -7590,6 +13203,8 @@ } async function bootstrap() { + await loadReadingCandidateCodePreferences(); + if (global.PracticeTimerPreferences?.ready) await global.PracticeTimerPreferences.ready; parseQuery(); captureDom(); const dataset = await ensureDataset(); @@ -7622,11 +13237,15 @@ attachUnifiedTimer(); attachUnifiedPanels(); + ensureReadingNotesUi(); + ensureReadingDisplayControls(); + await loadReadingDisplayPreferences(); attachSelectionHighlightToolbar(); attachReviewHighlightDictionary(); attachActionListeners(); attachMessageBridge(); attachPracticeTimerBridge(); + attachReadingDraftLifecycleHooks(); syncSuiteModeState(); setExitButtonVisible(false); if (state.memorizeMode) { @@ -7634,6 +13253,7 @@ } updateNavStatuses(); refreshSimulationDraftSyncLifecycle(); + refreshReadingDraftSyncLifecycle(); startInitLoop(); } @@ -7652,6 +13272,10 @@ (function markBundleProvided(global) { if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { global.AppLazyLoader.markProvided([ + "js/data/practiceRecordSource.js", + "js/data/v2/dataCatalog.js", + "js/data/v2/dataKernel.js", + "js/data/v2/appData.js", "js/runtime/readingExamRegistry.js", "js/runtime/readingExplanationRegistry.js", "js/runtime/readingHighlightShared.js", diff --git a/js/bundles/runtime-entry.bundle.js b/js/bundles/runtime-entry.bundle.js index 2090ee82..728925dc 100644 --- a/js/bundles/runtime-entry.bundle.js +++ b/js/bundles/runtime-entry.bundle.js @@ -536,11 +536,7 @@ function start(themeName = null) { if (!themeName) { - try { - themeName = localStorage.getItem('three_bg_theme') || 'floral-bloom'; - } catch(e) { - themeName = 'floral-bloom'; - } + themeName = 'floral-bloom'; } try { @@ -583,14 +579,20 @@ } global.switchBgTheme = function(themeName) { - try { - localStorage.setItem('three_bg_theme', themeName); - } catch(e){} + if (global.AppData && global.AppData.preferences) { + global.AppData.preferences.setThreeBackground(themeName).catch((error) => console.warn('[SHUI Three Background] preference save failed:', error)); + } start(themeName); }; - function init() { - start(); + async function init() { + try { + await global.AppData.ready; + const saved = await global.AppData.preferences.getThreeBackground(); + start(saved || 'floral-bloom'); + } catch (_) { + start('floral-bloom'); + } } if (document.readyState === 'complete' || document.readyState === 'interactive') { @@ -753,9 +755,7 @@ 'js/bundles/theme.bundle.js' ]; - manifest['settings-tools'] = [ - 'js/bundles/settings.bundle.js' - ]; + manifest['settings-tools'] = []; manifest['diagnostics-tools'] = [ 'js/bundles/diagnostics.bundle.js' @@ -764,13 +764,16 @@ dependencies['state-core'] = []; dependencies['exam-data'] = []; dependencies['practice-suite'] = ['state-core']; - dependencies['browse-runtime'] = ['state-core']; - dependencies['browse-view'] = ['state-core']; + // Browsing is also the entry point for starting a practice session. + // Keep the real recorder ready before a user can open an exam; the + // bootstrap fallback cannot own the full submit/persist round trip. + dependencies['browse-runtime'] = ['state-core', 'practice-suite']; + dependencies['browse-view'] = ['state-core', 'practice-suite']; dependencies['session-suite'] = ['browse-runtime', 'practice-suite']; dependencies['settings-tools'] = ['state-core']; - dependencies['more-tools'] = ['state-core', 'settings-tools']; + dependencies['more-tools'] = ['state-core']; dependencies['theme-tools'] = []; - dependencies['diagnostics-tools'] = ['state-core', 'settings-tools']; + dependencies['diagnostics-tools'] = ['state-core']; } function setBuiltInListeningAvailability(available, reason) { @@ -1072,10 +1075,6 @@ (function initSuitePreferenceUtils(global) { 'use strict'; - const FLOW_MODE_STORAGE_KEY = 'suite_flow_mode'; - const FREQUENCY_SCOPE_STORAGE_KEY = 'suite_frequency_scope'; - const AUTO_ADVANCE_STORAGE_KEY = 'suite_auto_advance_after_submit'; - const FLOW_MODES = ['classic', 'simulation', 'stationary']; const FREQUENCY_SCOPES = ['high', 'high_medium', 'all', 'custom']; @@ -1190,50 +1189,54 @@ return null; } - function readStorageValue(key) { - try { - if (global.localStorage && typeof global.localStorage.getItem === 'function') { - return global.localStorage.getItem(key); - } - } catch (_) { - // ignore read failures - } - return null; - } - - function writeStorageValue(key, value) { - try { - if (global.localStorage && typeof global.localStorage.setItem === 'function') { - global.localStorage.setItem(key, String(value)); - } - } catch (_) { - // ignore write failures + let hydrationPromise = null; + function hydrateSuitePreference() { + if (hydrationPromise) return hydrationPromise; + // runtime-entry.bundle.js is intentionally loaded before the data + // foundation. Do not memoize that early miss: a cached `false` would + // make every later resolver skip the persisted AppData preference. + if (!global.AppData || !global.AppData.preferences) { + return Promise.resolve(false); } + hydrationPromise = Promise.resolve().then(async () => { + await global.AppData.ready; + const stored = await global.AppData.preferences.getSuite(); + if (stored && typeof stored === 'object') Object.assign(ensurePracticeConfig().suite, stored); + return true; + }).catch((error) => { + console.warn('[SuitePreference] 加载失败:', error); + return false; + }); + // A transient AppData initialization failure should be retryable on the + // next read, just like the pre-foundation early miss above. + hydrationPromise = hydrationPromise.then((hydrated) => { + if (!hydrated) hydrationPromise = null; + return hydrated; + }); + return hydrationPromise; } - function resolveSuitePreference(overrides = {}) { + async function resolveSuitePreference(overrides = {}) { + await hydrateSuitePreference(); const config = ensurePracticeConfig(); const suiteConfig = config.suite || {}; const flowMode = normalizeFlowMode(overrides.flowMode) || normalizeFlowMode(suiteConfig.flowMode) - || normalizeFlowMode(readStorageValue(FLOW_MODE_STORAGE_KEY)) || 'classic'; const frequencyScope = normalizeFrequencyScope(overrides.frequencyScope) || normalizeFrequencyScope(suiteConfig.frequencyScope) - || normalizeFrequencyScope(readStorageValue(FREQUENCY_SCOPE_STORAGE_KEY)) || 'all'; const overrideAutoAdvance = parseBoolean(overrides.autoAdvanceAfterSubmit); const configAutoAdvance = parseBoolean(suiteConfig.autoAdvanceAfterSubmit); - const storedAutoAdvance = parseBoolean(readStorageValue(AUTO_ADVANCE_STORAGE_KEY)); const fallbackAutoAdvance = flowMode !== 'stationary'; const autoAdvanceAfterSubmit = overrideAutoAdvance != null ? overrideAutoAdvance : (configAutoAdvance != null ? configAutoAdvance - : (storedAutoAdvance != null ? storedAutoAdvance : fallbackAutoAdvance)); + : fallbackAutoAdvance); config.suite.flowMode = flowMode; config.suite.frequencyScope = frequencyScope; @@ -1247,24 +1250,34 @@ } function persistSuitePreference(partial = {}) { - const current = resolveSuitePreference(); + const config = ensurePracticeConfig(); + const suiteConfig = config.suite || {}; + const fallbackCurrent = { + flowMode: normalizeFlowMode(suiteConfig.flowMode) || 'classic', + frequencyScope: normalizeFrequencyScope(suiteConfig.frequencyScope) || 'all', + autoAdvanceAfterSubmit: parseBoolean(suiteConfig.autoAdvanceAfterSubmit) + }; - const flowMode = normalizeFlowMode(partial.flowMode) || current.flowMode; - const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || current.frequencyScope; + const flowMode = normalizeFlowMode(partial.flowMode) || fallbackCurrent.flowMode; + const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || fallbackCurrent.frequencyScope; const partialAutoAdvance = parseBoolean(partial.autoAdvanceAfterSubmit); const autoAdvanceAfterSubmit = partialAutoAdvance != null ? partialAutoAdvance : (flowMode === 'stationary' ? false : true); - const config = ensurePracticeConfig(); config.suite.flowMode = flowMode; config.suite.frequencyScope = frequencyScope; config.suite.autoAdvanceAfterSubmit = autoAdvanceAfterSubmit; - writeStorageValue(FLOW_MODE_STORAGE_KEY, flowMode); - writeStorageValue(FREQUENCY_SCOPE_STORAGE_KEY, frequencyScope); - writeStorageValue(AUTO_ADVANCE_STORAGE_KEY, autoAdvanceAfterSubmit ? 'true' : 'false'); + hydrateSuitePreference().then((hydrated) => { + if (!hydrated || !global.AppData || !global.AppData.preferences) return; + return global.AppData.preferences.patchSuite({ + flowMode, + frequencyScope, + autoAdvanceAfterSubmit + }); + }).catch((error) => console.warn('[SuitePreference] 保存失败:', error)); return { flowMode, @@ -1281,12 +1294,19 @@ normalizeFrequencyScope, normalizeFrequency, isFrequencyIncluded, + ready: hydrateSuitePreference, resolveSuitePreference, persistSuitePreference }; global.SuitePreferenceUtils = api; + // Kick hydration off eagerly so any later resolver (including the + // synchronous readers inside suitePracticeMixin) does not race the very + // first AppData.preferences.getSuite() lookup. If the data foundation is + // not installed yet, hydrateSuitePreference deliberately retries later. + hydrateSuitePreference(); + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } @@ -1430,11 +1450,11 @@ if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') { frequencyScope = 'all'; } - return { + return Promise.resolve({ flowMode: flowMode, frequencyScope: frequencyScope, autoAdvanceAfterSubmit: flowMode !== 'stationary' - }; + }); } function persistSuitePreference(partial) { @@ -1442,7 +1462,22 @@ if (suitePreferenceUtils && typeof suitePreferenceUtils.persistSuitePreference === 'function') { return suitePreferenceUtils.persistSuitePreference(partial || {}); } - return resolveSuitePreference(partial || {}); + // Fallback persists locally; resolveSuitePreference() above is async, + // but persistSuitePreference itself must remain synchronous so callers + // can read .flowMode/.frequencyScope immediately. Compute inline. + var flowMode = String(partial && partial.flowMode || '').trim().toLowerCase(); + if (flowMode !== 'classic' && flowMode !== 'simulation' && flowMode !== 'stationary') { + flowMode = 'classic'; + } + var frequencyScope = String(partial && partial.frequencyScope || '').trim().toLowerCase(); + if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') { + frequencyScope = 'all'; + } + return { + flowMode: flowMode, + frequencyScope: frequencyScope, + autoAdvanceAfterSubmit: flowMode !== 'stationary' + }; } function persistSuiteFlowMode(mode) { @@ -1457,9 +1492,9 @@ function promptSuiteModeSelection() { return new Promise(function resolveSelection(resolve) { - var preselectedPreference = resolveSuitePreference(); - var preselected = preselectedPreference.flowMode || 'classic'; - var preselectedScope = preselectedPreference.frequencyScope || 'all'; + resolveSuitePreference().then(function applyPreselection(preselectedPreference) { + var preselected = (preselectedPreference && preselectedPreference.flowMode) || 'classic'; + var preselectedScope = (preselectedPreference && preselectedPreference.frequencyScope) || 'all'; var search = ''; try { search = String(global.location && global.location.search || '').toLowerCase(); @@ -1570,6 +1605,7 @@ } }); global.document.body.appendChild(host); + }); }); } @@ -1675,34 +1711,6 @@ } } - function getExamIndexSnapshot() { - if (typeof global.getExamIndexState === 'function') { - try { - var snapshot = global.getExamIndexState(); - if (Array.isArray(snapshot) && snapshot.length) { - return snapshot.slice(); - } - } catch (_) { } - } - if (Array.isArray(global.examIndex) && global.examIndex.length) { - return global.examIndex.slice(); - } - if (typeof global.getReadingExamIndex === 'function') { - var readingIndex = global.getReadingExamIndex(); - if (Array.isArray(readingIndex) && readingIndex.length) { - return readingIndex.map(function (exam) { - return Object.assign({}, exam, { type: exam.type || 'reading' }); - }); - } - } - if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) { - return global.__READING_EXAM_INDEX__.map(function (exam) { - return Object.assign({}, exam, { type: exam.type || 'reading' }); - }); - } - return []; - } - function isReadingMemorizeCandidate(exam) { if (!exam || !exam.id) { return false; @@ -1892,12 +1900,8 @@ }); } - function startRandomPractice(category, type, filterMode, path) { - var getExamIndexState = global.getExamIndexState || function () { - return Array.isArray(global.examIndex) ? global.examIndex : []; - }; - - var list = getExamIndexState(); + async function startRandomPractice(category, type, filterMode, path) { + var list = await global.resolveActiveLibraryIndex(); var normalizedType = (!type || type === 'all') ? null : type; var normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null; @@ -1998,11 +2002,8 @@ }, 1000); } - function pickRandomExam() { - var getExamIndexState = global.getExamIndexState || function () { - return Array.isArray(global.examIndex) ? global.examIndex : []; - }; - var list = getExamIndexState().filter(function (e) { + function pickRandomExam(examIndex) { + var list = (Array.isArray(examIndex) ? examIndex : []).filter(function (e) { return e && e.hasHtml && e.type === 'reading'; }); if (!list.length) return null; @@ -2024,6 +2025,9 @@ } // resolve to absolute url = new URL(url, window.location.href).href; + var parsedUrl = new URL(url); + parsedUrl.searchParams.set('endless', '1'); + url = parsedUrl.href; } catch (_) { } if (!url) return null; @@ -2048,14 +2052,21 @@ if (!endlessState || !endlessState.active) return; var countdown = ENDLESS_COUNTDOWN_SEC; + var postEndlessControl = function (type, data) { + if (!endlessState || !endlessState.currentExamId || !global.app + || typeof global.app._postExamMessage !== 'function') return false; + return global.app._postExamMessage( + endlessState.currentExamId, + sourceWindow, + type, + data || {} + ); + }; // 通知练习页开始倒计时 try { if (sourceWindow && !sourceWindow.closed) { - sourceWindow.postMessage({ - type: 'ENDLESS_COUNTDOWN', - data: { seconds: countdown } - }, '*'); + postEndlessControl('ENDLESS_COUNTDOWN', { seconds: countdown }); } } catch (_) { } @@ -2076,10 +2087,7 @@ // 持续更新倒计时 try { if (sourceWindow && !sourceWindow.closed) { - sourceWindow.postMessage({ - type: 'ENDLESS_COUNTDOWN_TICK', - data: { seconds: countdown } - }, '*'); + postEndlessControl('ENDLESS_COUNTDOWN_TICK', { seconds: countdown }); } } catch (_) { } @@ -2089,16 +2097,13 @@ try { if (sourceWindow && !sourceWindow.closed) { - sourceWindow.postMessage({ - type: 'ENDLESS_COUNTDOWN_END', - data: {} - }, '*'); + postEndlessControl('ENDLESS_COUNTDOWN_END', {}); } } catch (_) { } if (!endlessState || !endlessState.active) return; - var nextExam = pickRandomExam(); + var nextExam = pickRandomExam(endlessState.examIndex); if (!nextExam) { if (typeof global.showMessage === 'function') { global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a', 'warning'); @@ -2112,21 +2117,32 @@ } var reuseWin = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null; - var newWin = openEndlessExam(nextExam, reuseWin); - if (newWin) { - endlessState.currentWindow = newWin; - if (global.app && typeof global.app.setupExamWindowManagement === 'function') { - global.app.setupExamWindowManagement(newWin, nextExam.id, nextExam, {}); + var openNext = global.app && typeof global.app.openExam === 'function' + ? global.app.openExam(nextExam.id, { + target: 'tab', + windowName: ENDLESS_WINDOW_NAME, + reuseWindow: reuseWin, + endlessMode: true + }) + : openEndlessExam(nextExam, reuseWin); + Promise.resolve(openNext).then(function (newWin) { + if (!newWin || !endlessState || !endlessState.active) { + throw new Error('无法打开下一题'); } - if (global.app && typeof global.app.startPracticeSession === 'function') { - try { global.app.startPracticeSession(nextExam.id); } catch (_) { } + endlessState.currentWindow = newWin; + endlessState.currentExamId = nextExam.id; + }).catch(function (error) { + if (global.console && console.error) console.error('[EndlessMode] 打开下一题失败:', error); + if (typeof global.showMessage === 'function') { + global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u4e0b\u4e00\u9898', 'error'); } - } + stopEndlessPractice({ silent: true }); + }); } }, 1000); } - function startEndlessPractice() { + async function startEndlessPractice() { // 如果已激活,不再走“父页按钮二次点击退出”的伪交互 if (endlessState && endlessState.active) { if (typeof global.showMessage === 'function') { @@ -2135,7 +2151,8 @@ return; } - var firstExam = pickRandomExam(); + var examIndex = await global.resolveActiveLibraryIndex(); + var firstExam = pickRandomExam(examIndex); if (!firstExam) { if (typeof global.showMessage === 'function') { global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a\uff0c\u8bf7\u5148\u52a0\u8f7d\u9898\u5e93', 'error'); @@ -2146,8 +2163,10 @@ // 标记状态 endlessState = { active: true, + examIndex: examIndex, countdownTimer: null, currentWindow: null, + currentExamId: firstExam.id, messageHandler: null, windowMonitor: null }; @@ -2157,6 +2176,25 @@ if (!endlessState || !endlessState.active) return; var msg = event && event.data; if (!msg || typeof msg.type !== 'string') return; + var currentWindow = endlessState.currentWindow; + if (!currentWindow || event.source !== currentWindow) return; + var info = global.app && global.app.examWindows && endlessState.currentExamId + ? global.app.examWindows.get(endlessState.currentExamId) + : null; + if (info && info.expectedOrigin && info.expectedOrigin !== 'null') { + if (event.origin !== info.expectedOrigin) return; + } else if (info && info.allowOpaqueOrigin) { + if (event.origin !== 'null') return; + } else { + return; + } + var messageData = msg.data || {}; + var permitsPreInit = msg.type === 'REQUEST_INIT'; + if (!permitsPreInit && ( + msg.source !== 'practice_page' + || !info.windowSessionToken + || messageData.windowSessionToken !== info.windowSessionToken + )) return; if (msg.type === 'ENDLESS_USER_EXIT') { stopEndlessPractice(); return; @@ -2190,19 +2228,27 @@ // 优先用 app.openExam 保证注入 if (global.app && typeof global.app.openExam === 'function') { try { - Promise.resolve(global.app.openExam(firstExam.id, { + win = await global.app.openExam(firstExam.id, { target: 'tab', - windowName: ENDLESS_WINDOW_NAME - })).then(function (w) { - if (w && endlessState) endlessState.currentWindow = w; - startEndlessWindowMonitor(); - }).catch(function () { }); - } catch (_) { } + windowName: ENDLESS_WINDOW_NAME, + endlessMode: true + }); + } catch (error) { + if (global.console && console.error) console.error('[EndlessMode] 打开首题失败:', error); + } } else { win = openEndlessExam(firstExam, null); - if (win && endlessState) endlessState.currentWindow = win; - startEndlessWindowMonitor(); } + if (!win || !endlessState) { + stopEndlessPractice({ silent: true }); + if (typeof global.showMessage === 'function') { + global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u7ec3\u4e60\u7a97\u53e3', 'error'); + } + return; + } + endlessState.currentWindow = win; + endlessState.currentExamId = firstExam.id; + startEndlessWindowMonitor(); } global.AppActions = Object.assign({}, global.AppActions, { diff --git a/js/bundles/session.bundle.js b/js/bundles/session.bundle.js index 30590546..164caa8c 100644 --- a/js/bundles/session.bundle.js +++ b/js/bundles/session.bundle.js @@ -11,7 +11,7 @@ function resolveSuitePreferenceForMixin(options = {}) { const suitePreferenceUtils = getSuitePreferenceUtils(); if (suitePreferenceUtils && typeof suitePreferenceUtils.resolveSuitePreference === 'function') { - return suitePreferenceUtils.resolveSuitePreference(options); + return suitePreferenceUtils.ensurePracticeConfig().suite || {}; } let flowMode = String(options && options.flowMode || '').trim().toLowerCase(); if (!['classic', 'simulation', 'stationary'].includes(flowMode)) { @@ -174,16 +174,26 @@ } }, async handleSuitePracticeComplete(examId, data, sourceWindow = null) { + const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => ( + data && data.submissionId + ? Object.assign({ + handled: Boolean(handled), + committed: Boolean(committed), + errorCode: errorCode || null + }, extra || {}) + : Boolean(handled) + ); // First check whether this is multi-suite mode (detected via suiteId). if (data && data.suiteId) { - return await this.handleMultiSuitePracticeComplete(examId, data); + const committed = await this.handleMultiSuitePracticeComplete(examId, data); + return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed'); } if (data && data.suiteSubmission === true && typeof this._handleInlineSimulationSuiteSubmit === 'function') { return await this._handleInlineSimulationSuiteSubmit(examId, data, sourceWindow); } const session = this.currentSuiteSession; - if (!session || session.status !== 'active') { + if (!session) { return false; } @@ -193,6 +203,14 @@ if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) { return false; } + if (session.status === 'completed') { + return withSubmitOutcome(true, true, '', data && data.submissionId ? { + teardownSession: session + } : null); + } + if (session.status !== 'active') { + return false; + } const mappingMissing = !this.suiteExamMap || !this.suiteExamMap.has(examId); if (mappingMissing && typeof this._registerSuiteSequence === 'function') { @@ -222,7 +240,7 @@ submittedExamId: examId, sessionId: session.id }); - return true; + return withSubmitOutcome(true, false, 'inactive_suite_exam'); } const derivedDuration = this._deriveSuiteExamElapsedSeconds(session, examId, data && data.duration); @@ -260,7 +278,7 @@ if (replayWindow) { await this._sendSuiteReviewState(session, examId, replayWindow); } - return true; + return withSubmitOutcome(true, true); } session.currentIndex = currentIndex + 1; @@ -269,8 +287,11 @@ // Last passage -> finalize the entire simulation if (session.currentIndex >= session.sequence.length) { - await this.finalizeSuiteRecord(session); - return true; + const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed); + const committed = await this.finalizeSuiteRecord(session, { deferTeardown }); + return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? { + teardownSession: session + } : null); } // Not last -> advance to next passage @@ -282,7 +303,8 @@ } } - return this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId); + const advanced = await this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId); + return withSubmitOutcome(advanced, advanced, advanced ? '' : 'suite_advance_failed'); }, async continueSuitePractice() { @@ -298,8 +320,17 @@ }, async _handleInlineSimulationSuiteSubmit(examId, data, sourceWindow = null) { + const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => ( + data && data.submissionId + ? Object.assign({ + handled: Boolean(handled), + committed: Boolean(committed), + errorCode: errorCode || null + }, extra || {}) + : Boolean(handled) + ); const session = this.currentSuiteSession; - if (!session || session.status !== 'active' || session.flowMode !== 'simulation') { + if (!session || session.flowMode !== 'simulation') { return false; } const payloadSuiteSessionId = data && typeof data.suiteSessionId === 'string' @@ -308,9 +339,17 @@ if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) { return false; } + if (session.status === 'completed') { + return withSubmitOutcome(true, true, '', data && data.submissionId ? { + teardownSession: session + } : null); + } + if (session.status !== 'active') { + return false; + } const suiteEntries = Array.isArray(data && data.suiteEntries) ? data.suiteEntries : []; if (!suiteEntries.length) { - return false; + return withSubmitOutcome(true, false, 'suite_entries_missing'); } const entriesByExam = new Map(); suiteEntries.forEach((entry) => { @@ -320,7 +359,7 @@ } }); if (!entriesByExam.size) { - return false; + return withSubmitOutcome(true, false, 'suite_entries_missing'); } const hasEverySequenceEntry = Array.isArray(session.sequence) && session.sequence.length > 0 @@ -334,7 +373,7 @@ expected: session.sequence.map(item => item && item.examId).filter(Boolean), received: Array.from(entriesByExam.keys()) }); - return false; + return withSubmitOutcome(true, false, 'suite_entries_incomplete'); } session.results = []; @@ -358,6 +397,8 @@ answers: entryPayload.answers || {}, highlights: Array.isArray(entryPayload.highlights) ? entryPayload.highlights.slice() : [], noteText: typeof entryPayload.noteText === 'string' ? entryPayload.noteText : '', + notes: Array.isArray(entryPayload.notes) ? entryPayload.notes.slice() : [], + noteOutlines: Array.isArray(entryPayload.noteOutlines) ? entryPayload.noteOutlines.slice() : [], scrollY: Number.isFinite(Number(entryPayload.scrollY)) ? Number(entryPayload.scrollY) : 0, markedQuestions: Array.isArray(entryPayload.markedQuestions) ? entryPayload.markedQuestions.slice() : [] }, @@ -381,8 +422,11 @@ session.windowRef = sourceWindow; } this._mirrorSessionToStorage(session); - await this.finalizeSuiteRecord(session); - return true; + const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed); + const committed = await this.finalizeSuiteRecord(session, { deferTeardown }); + return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? { + teardownSession: session + } : null); }, _resolveSuitePreference(options = {}) { @@ -438,7 +482,7 @@ if (data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft)) { return true; } - return ['answers', 'highlights', 'noteText', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => ( + return ['answers', 'highlights', 'noteText', 'notes', 'noteOutlines', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => ( Object.prototype.hasOwnProperty.call(data, key) )); }, @@ -470,6 +514,12 @@ const noteTextSource = typeof draftSource.noteText === 'string' ? draftSource.noteText : (data && typeof data.noteText === 'string' ? data.noteText : ''); + const notesSource = Array.isArray(draftSource.notes) + ? draftSource.notes + : (Array.isArray(data && data.notes) ? data.notes : []); + const noteOutlinesSource = Array.isArray(draftSource.noteOutlines) + ? draftSource.noteOutlines + : (Array.isArray(data && data.noteOutlines) ? data.noteOutlines : []); const scrollSource = Number.isFinite(Number(draftSource.scrollY)) ? Number(draftSource.scrollY) : (Number.isFinite(Number(data && data.scrollY)) ? Number(data.scrollY) : 0); @@ -483,6 +533,8 @@ answers: this._cloneSuiteDraftPlainObject(answerSource), highlights: highlightSource.slice(), noteText: noteTextSource, + notes: this._cloneSuitePlainObject(notesSource), + noteOutlines: this._cloneSuitePlainObject(noteOutlinesSource), scrollY: scrollSource, markedQuestions: markedQuestionsSource.slice(), updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now() @@ -558,6 +610,8 @@ delete cloned.highlights; delete cloned.scrollY; delete cloned.noteText; + delete cloned.notes; + delete cloned.noteOutlines; return cloned; }, @@ -576,6 +630,14 @@ if (noteText) { rawData.noteText = noteText; } + const notes = this._resolveSuiteEntryNotes(entry, draft); + if (notes.length > 0) { + rawData.notes = notes; + } + const noteOutlines = this._resolveSuiteEntryNoteOutlines(entry, draft); + if (noteOutlines.length > 0) { + rawData.noteOutlines = noteOutlines; + } return rawData; }, @@ -585,8 +647,11 @@ entry && entry.highlights, entry && entry.rawData && entry.rawData.highlights ]; + // 把“存在数组”视为权威来源(即使为空也可代表用户已清空高亮), + // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 highlights: [] + // 被跳过而回落到旧 entry.rawData.highlights,复活已删除的高亮。 for (const source of sources) { - if (Array.isArray(source) && source.length > 0) { + if (source != null && Array.isArray(source)) { return source.slice(); } } @@ -634,6 +699,40 @@ return ''; }, + _resolveSuiteEntryNotes(entry, draft = null) { + const sources = [ + draft && draft.notes, + entry && entry.notes, + entry && entry.rawData && entry.rawData.notes + ]; + // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条结构笔记), + // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 notes: [] + // 被跳过而回落到旧 entry.rawData.notes,复活已删除的笔记。 + for (const source of sources) { + if (source != null && Array.isArray(source)) { + return this._cloneSuitePlainObject(source); + } + } + return []; + }, + + _resolveSuiteEntryNoteOutlines(entry, draft = null) { + const sources = [ + draft && draft.noteOutlines, + entry && entry.noteOutlines, + entry && entry.rawData && entry.rawData.noteOutlines + ]; + // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条笔记大纲), + // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 noteOutlines: [] + // 被跳过而回落到旧 entry.rawData.noteOutlines,复活已删除的大纲。 + for (const source of sources) { + if (source != null && Array.isArray(source)) { + return this._cloneSuitePlainObject(source); + } + } + return []; + }, + _buildSuiteReplayEntry(session, examId) { if (!session || !Array.isArray(session.results)) { return null; @@ -698,6 +797,8 @@ const highlights = this._resolveSuiteEntryHighlights(result, draft); const noteText = this._resolveSuiteEntryNoteText(result, draft); + const notes = this._resolveSuiteEntryNotes(result, draft); + const noteOutlines = this._resolveSuiteEntryNoteOutlines(result, draft); const scrollY = this._resolveSuiteEntryScrollY(result, draft); const markedQuestions = result && Array.isArray(result.markedQuestions) ? result.markedQuestions.slice() @@ -708,6 +809,8 @@ || markedQuestions.length || highlights.length || noteText + || notes.length + || noteOutlines.length || (Number.isFinite(Number(scrollY)) && Number(scrollY) > 0) ); if (!hasReplayData) { @@ -722,6 +825,8 @@ markedQuestions, highlights, noteText, + notes, + noteOutlines, scrollY }; }, @@ -783,18 +888,15 @@ } try { if (replayEntry) { - resolvedWindow.postMessage({ - type: 'REPLAY_PRACTICE_RECORD', - data: { - suiteSessionId: session.id, - reviewEntryIndex: contextPayload.currentIndex, - readOnly: contextPayload.readOnly !== false, - markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [], - entry: replayEntry - } - }, '*'); + this._postExamMessage(examId, resolvedWindow, 'REPLAY_PRACTICE_RECORD', { + suiteSessionId: session.id, + reviewEntryIndex: contextPayload.currentIndex, + readOnly: contextPayload.readOnly !== false, + markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [], + entry: replayEntry + }); } - resolvedWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*'); + this._postExamMessage(examId, resolvedWindow, 'REVIEW_CONTEXT', contextPayload); return true; } catch (error) { console.warn('[SuitePractice] 发送套题回看上下文失败:', error); @@ -829,7 +931,7 @@ && Number(windowInfo.lastMessageAt) >= startedAt && (!windowInfo.suiteSessionId || windowInfo.suiteSessionId === session.id) && (!windowInfo.windowSessionToken || !windowInfo.lastWindowSessionToken || windowInfo.windowSessionToken === windowInfo.lastWindowSessionToken) - && (!windowInfo.pageType || /unified-reading|suite-placeholder/i.test(String(windowInfo.pageType))) + && (!windowInfo.pageType || /unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(String(windowInfo.pageType))) ); if (readyMatches) { return true; @@ -873,7 +975,7 @@ const pageType = windowInfo && typeof windowInfo.pageType === 'string' ? windowInfo.pageType.toLowerCase() : ''; - if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) { + if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) { return false; } return true; @@ -1009,6 +1111,7 @@ } if (isCrossExamNavigation || !targetWindow) { targetWindow = await this.openExam(targetEntry.examId, { + examDefinition: targetEntry.exam, target: 'tab', windowName: session.windowName || 'ielts-suite-mode-tab', suiteSessionId: session.id, @@ -1086,7 +1189,10 @@ } try { - const opened = await this.openExam(nextEntry.examId, options); + const opened = await this.openExam(nextEntry.examId, { + ...options, + examDefinition: nextEntry.exam + }); if (opened && !opened.closed) { return opened; } @@ -1172,18 +1278,13 @@ startTime: session.startTime, activeExamId: session.activeExamId }; - if (global.sessionStorage) { - global.sessionStorage.setItem('ielts_sim_session', JSON.stringify(snapshot)); - } + global.AppData.recovery.windowSession.save('simulation', snapshot); } catch (_) { /* file:// may not support */ } }, _restoreSessionFromStorage() { try { - if (!global.sessionStorage) return null; - const raw = global.sessionStorage.getItem('ielts_sim_session'); - if (!raw) return null; - const snapshot = JSON.parse(raw); + const snapshot = global.AppData.recovery.windowSession.get('simulation'); if (!snapshot || !snapshot.id || !Array.isArray(snapshot.sequence)) return null; return snapshot; } catch (_) { return null; } @@ -1191,9 +1292,7 @@ _clearSessionStorage() { try { - if (global.sessionStorage) { - global.sessionStorage.removeItem('ielts_sim_session'); - } + global.AppData.recovery.windowSession.discard('simulation'); } catch (_) { /* ignore */ } }, @@ -1303,8 +1402,6 @@ const pausedAtMs = Number.isFinite(Number(session.suiteTimerPausedAtMs)) ? Number(session.suiteTimerPausedAtMs) : null; const suiteTimerRunning = session.suiteTimerRunning !== false; const payload = { - type: 'SIMULATION_CONTEXT', - data: { suiteSessionId: session.id, flowMode: session.flowMode || 'simulation', examId, @@ -1333,10 +1430,9 @@ pausedAtMs, running: suiteTimerRunning } - } }; try { - targetWindow.postMessage(payload, '*'); + this._postExamMessage(examId, targetWindow, 'SIMULATION_CONTEXT', payload); return true; } catch (e) { console.warn('[SuitePractice] 发送模拟上下文失败:', e); @@ -1348,7 +1444,16 @@ const session = this.currentSuiteSession; if (!session || session.status !== 'active') return false; if (session.flowMode !== 'simulation') return false; - if (session.simulationNavigateLocked === true) return false; + if (session.simulationNavigateLocked === true) { + const inFlight = this._simulationNavigateInFlight; + if (!inFlight || typeof inFlight.then !== 'function') return false; + try { + await inFlight; + } catch (_) { + // The queued request still gets its own validation and error path. + } + return this._handleSimulationNavigate(examId, data, sourceWindow); + } const normalizedExamId = examId != null ? String(examId).trim() : ''; const activeExamId = session.activeExamId != null ? String(session.activeExamId).trim() : ''; if (!normalizedExamId) return false; @@ -1362,6 +1467,11 @@ } session.activeExamId = normalizedExamId; } + let releaseNavigation; + const navigationInFlight = new Promise((resolve) => { + releaseNavigation = resolve; + }); + this._simulationNavigateInFlight = navigationInFlight; session.simulationNavigateLocked = true; try { @@ -1405,6 +1515,7 @@ session.activeExamId = targetEntry.examId; const targetWindow = await this.openExam(targetEntry.examId, { + examDefinition: targetEntry.exam, target: 'tab', windowName: session.windowName || 'ielts-suite-mode-tab', suiteSessionId: session.id, @@ -1444,6 +1555,10 @@ return true; } finally { session.simulationNavigateLocked = false; + if (this._simulationNavigateInFlight === navigationInFlight) { + this._simulationNavigateInFlight = null; + } + releaseNavigation(); } }, @@ -1464,7 +1579,7 @@ if (!session || (session.status !== 'active' && session.status !== 'initializing') || !examId) { return false; } - if (session.flowMode !== 'simulation') { + if (session.flowMode !== 'simulation' && session.flowMode !== 'stationary') { return false; } if (!Array.isArray(session.sequence) || !session.sequence.length) { @@ -1486,7 +1601,7 @@ const pageType = windowInfo && typeof windowInfo.pageType === 'string' ? windowInfo.pageType.toLowerCase() : ''; - if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) { + if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) { return false; } let targetWindow = session.windowRef && !session.windowRef.closed ? session.windowRef : null; @@ -1531,11 +1646,14 @@ session.activeExamId = examId; session.windowRef = targetWindow; this._mirrorSessionToStorage(session); - if (session._contextSentExamId === examId + if (session.flowMode === 'simulation' && session._contextSentExamId === examId && Number.isFinite(Number(session._contextSentAt)) && Date.now() - session._contextSentAt < 3000) { return true; } + if (session.flowMode === 'stationary') { + return this._sendSuiteReviewState(session, examId, targetWindow); + } return this._sendSimulationContext(session, examId, targetWindow); }, @@ -1563,6 +1681,9 @@ if (alreadyRecorded) { console.warn('[MultiSuite] 套题已记录,跳过:', suiteData.suiteId); + if (session.status !== 'completed' && this.isMultiSuiteComplete(session)) { + return await this.finalizeMultiSuiteRecord(session); + } return true; } @@ -1605,8 +1726,7 @@ // 检查是否所有套题都已完成 if (this.isMultiSuiteComplete(session)) { console.log('[MultiSuite] all suite entries completed, finalizing consolidated record.'); - await this.finalizeMultiSuiteRecord(session); - return true; + return await this.finalizeMultiSuiteRecord(session); } // 还有套题未完成,保存当前进度 @@ -1653,12 +1773,13 @@ async finalizeMultiSuiteRecord(session) { if (!session || !Array.isArray(session.suiteResults) || session.suiteResults.length === 0) { console.warn('[MultiSuite] 无效的会话或无结果,跳过聚合'); - return; + return false; } session.status = 'finalizing'; console.log('[MultiSuite] 开始聚合多套题记录:', session.id); + let record = null; try { const completionTime = Date.now(); const startTime = session.startTime || completionTime; @@ -1688,7 +1809,7 @@ const displayTitle = dateLabel + ' ' + sourceLabel + ' multi-suite practice'; // 构建聚合记录 - const record = { + record = { id: session.id, examId: session.baseExamId, title: displayTitle, @@ -1753,36 +1874,49 @@ // 保存聚合记录 await this._saveSuitePracticeRecord(record); - - // 保存拼写错误到词表 - if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) { - try { - await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors); - console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length); - } catch (error) { - console.warn('[MultiSuite] 保存拼写错误失败:', error); - } + session.status = 'completed'; + } catch (error) { + console.error('[MultiSuite] 聚合记录失败:', error); + session.status = 'error'; + try { + window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error'); + } catch (notificationError) { + console.warn('[MultiSuite] 显示聚合保存失败通知时出错:', notificationError); } + return false; + } - // 更新状态 - await this._updatePracticeRecordsState(); + // From here on the aggregate record is authoritative. Every remaining action is best-effort + // and must not turn the committed submission into a NACK or another persistence attempt. + const aggregatedSpellingErrors = Array.isArray(record.spellingErrors) ? record.spellingErrors : []; + if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) { + await this._runSuitePostCommitStep('保存多套题拼写错误', async () => { + await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors); + console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length); + }); + } + await this._runSuitePostCommitStep('同步多套题练习记录', () => this._updatePracticeRecordsState()); + await this._runSuitePostCommitStep('刷新多套题总览', () => { this.refreshOverviewData && this.refreshOverviewData(); - - // 清理会话 + }); + await this._runSuitePostCommitStep('清理多套题会话', () => { this.multiSuiteSessionsMap.delete(session.baseExamId); - session.status = 'completed'; - + }); + await this._runSuitePostCommitStep('显示多套题完成通知', () => { window.showMessage && window.showMessage('多套题练习已完成,已保存 ' + session.suiteResults.length + ' 条套题记录。', 'success'); + }); + console.log('[MultiSuite] consolidated record saved:', record.id); + return true; + }, - - - console.log('[MultiSuite] consolidated record saved:', record.id); - + async _runSuitePostCommitStep(label, callback) { + try { + await callback(); + return true; } catch (error) { - console.error('[MultiSuite] 聚合记录失败:', error); - session.status = 'error'; - window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error'); + console.warn(`[SuitePractice] ${label}失败(聚合记录已保存):`, error); + return false; } }, @@ -1956,14 +2090,15 @@ return aggregated; }, - async finalizeSuiteRecord(session) { + async finalizeSuiteRecord(session, options = {}) { if (!session || !session.results || !session.results.length) { await this._teardownSuiteSession(session); - return; + return false; } session.status = 'finalizing'; + let committed = false; try { const completionTime = Date.now(); const suiteEntries = session.results.map(entry => { @@ -1979,6 +2114,8 @@ markedQuestions: Array.isArray(entry.markedQuestions) ? entry.markedQuestions.slice() : [], highlights: this._resolveSuiteEntryHighlights(entry, draft), noteText: this._resolveSuiteEntryNoteText(entry, draft), + notes: this._resolveSuiteEntryNotes(entry, draft), + noteOutlines: this._resolveSuiteEntryNoteOutlines(entry, draft), scrollY: this._resolveSuiteEntryScrollY(entry, draft), rawData: this._sanitizeSuiteRawData(entry.rawData) }; @@ -2077,55 +2214,55 @@ }; await this._saveSuitePracticeRecord(record); - await this._updatePracticeRecordsState(); - this.refreshOverviewData && this.refreshOverviewData(); - window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success'); + committed = true; session.status = 'completed'; } catch (error) { console.error('[SuitePractice] 保存套题记录失败:', error); - window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error'); - await this._savePartialSuiteAsIndividual(session); - session.status = 'error'; - } finally { - await this._teardownSuiteSession(session); - } - }, - - async _fetchSuiteExamIndex() { - let list = this.getState ? this.getState('exam.index') : null; - if (!Array.isArray(list) || !list.length) { try { - const activeKey = await storage.get('active_exam_index_key', 'exam_index'); - list = await storage.get(activeKey, []); - if (!Array.isArray(list) || !list.length) { - list = await storage.get('exam_index', []); - } - } catch (error) { - console.warn('[SuitePractice] Failed to load exam index, falling back to the default bank.', error); - list = await storage.get('exam_index', []); + window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error'); + } catch (notificationError) { + console.warn('[SuitePractice] 显示套题保存失败通知时出错:', notificationError); } + try { + await this._savePartialSuiteAsIndividual(session); + } catch (fallbackError) { + console.warn('[SuitePractice] 聚合记录未保存,单篇恢复也失败:', fallbackError); + } + session.status = options.deferTeardown ? 'active' : 'error'; + } + + if (committed) { + await this._runSuitePostCommitStep('同步套题练习记录', () => this._updatePracticeRecordsState()); + await this._runSuitePostCommitStep('刷新套题总览', () => { + this.refreshOverviewData && this.refreshOverviewData(); + }); + await this._runSuitePostCommitStep('显示套题完成通知', () => { + window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success'); + }); + } + + if (!options.deferTeardown) { + await this._runSuitePostCommitStep('清理套题会话窗口', () => this._teardownSuiteSession(session)); } + return committed; + }, + async _fetchSuiteExamIndex() { + const list = await window.resolveActiveLibraryIndex(); return Array.isArray(list) ? list.filter(Boolean) : []; }, async _listPracticeRecordsViaAPI() { const normalizeList = (list) => (Array.isArray(list) ? list : []); - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return normalizeList(await window.PracticeRecordAPI.list()); - } - - return []; + // Filtering needs suiteEntries and suite markers, but never highlights or notes. + // The detail projection contains those fields without loading the annotation layer. + return normalizeList(await window.AppData.practice.list({ projection: 'detail' })); }, async _recalculatePracticeStatsFromRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.recalculateStats === 'function') { - await window.PracticeRecordAPI.recalculateStats(); - return true; - } - console.warn('[SuitePractice] 统一练习统计 API 未就绪'); - return false; + await window.AppData.practice.getStats(); + return true; }, async _loadSuitePracticeRecordsForFiltering() { @@ -2458,6 +2595,7 @@ let examWindow = null; try { examWindow = await this.openExam(firstEntry.examId, { + examDefinition: firstEntry.exam, target: 'tab', windowName: suiteWindowName, suiteSessionId, @@ -2586,13 +2724,26 @@ }, async _saveSuitePracticeRecord(record) { - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.saveRecord !== 'function') { - throw new Error('统一练习记录存储未就绪'); - } - await window.PracticeRecordAPI.saveRecord(record, { updateStats: true }); - await this._cleanupSuiteEntryRecords(record).catch(error => { - console.warn('[SuitePractice] 清理套题子记录失败:', error); + const childSessionIds = []; + (Array.isArray(record && record.suiteEntries) ? record.suiteEntries : []).forEach((entry) => { + const raw = entry && entry.rawData || {}; + const sessionId = raw.sessionId || (entry && (entry.sessionId || entry.suiteEntrySessionId)); + if (sessionId && String(sessionId) !== String(record.sessionId || '')) childSessionIds.push(String(sessionId)); }); + const receipt = await window.AppData.practice.finalizeSuite({ + record, + childSessionIds, + operationId: record.operationId + || (record.submissionId + ? `practice-suite:${String(record.sessionId || 'session')}:${String(record.submissionId)}` + : undefined) + }); + if (!receipt || receipt.committed !== true) { + const error = new Error('Suite aggregate commit was not confirmed'); + error.code = 'SUITE_COMMIT_NOT_CONFIRMED'; + throw error; + } + return receipt.record || record; }, async _cleanupSuiteEntryRecords(record) { @@ -2625,14 +2776,7 @@ return; } - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.deleteMany !== 'function') { - throw new Error('统一练习记录删除 API 未就绪'); - } - const result = await window.PracticeRecordAPI.deleteMany(Array.from(entrySessionIds), { updateStats: true, matchBy: 'sessionId' }); - const deletedCount = Number(result && result.deletedCount) || 0; - if (deletedCount > 0) { - console.log('[SuitePractice] cleared ' + deletedCount + ' suite child records'); - } + // Child cleanup is committed atomically by practice.finalizeSuite. }, async _updatePracticeRecordsState() { @@ -2641,22 +2785,20 @@ await window.syncPracticeRecords({ forceRender: true }); return; } else { - const latest = await this._listPracticeRecordsViaAPI(); - if (this.setState) { - this.setState('practice.records', Array.isArray(latest) ? latest : []); + const [latest, index] = await Promise.all([ + window.AppData.practice.list({ projection: 'light' }), + window.resolveActiveLibraryIndex() + ]); + if (typeof window.refreshBrowseProgressFromRecords === 'function') { + window.refreshBrowseProgressFromRecords(latest, index); + } + if (typeof window.updatePracticeView === 'function') { + window.updatePracticeView(latest, index); } } } catch (error) { console.warn('[SuitePractice] 同步练习记录失败:', error); } - - try { - if (typeof window.updatePracticeView === 'function') { - window.updatePracticeView(); - } - } catch (error) { - console.warn('[SuitePractice] 刷新练习视图失败:', error); - } }, _formatSuiteDateLabel(timestamp) { @@ -2778,16 +2920,21 @@ return; } + if (session.submitReceiptTeardownTimer) { + clearTimeout(session.submitReceiptTeardownTimer); + session.submitReceiptTeardownTimer = null; + } + this._clearSuiteHandshakes(); if (session.windowRef && !session.windowRef.closed && typeof session.windowRef.postMessage === 'function') { try { - session.windowRef.postMessage({ - type: 'SUITE_FORCE_CLOSE', - data: { - suiteSessionId: session.id || null - } - }, '*'); + const activeExamId = session.activeExamId + || (session.sequence && session.sequence[session.currentIndex || 0] && session.sequence[session.currentIndex || 0].examId) + || ''; + this._postExamMessage(activeExamId, session.windowRef, 'SUITE_FORCE_CLOSE', { + suiteSessionId: session.id || null + }); } catch (forceCloseError) { console.warn('[SuitePractice] 无法通知套题窗口关闭:', forceCloseError); } diff --git a/js/bundles/settings.bundle.js b/js/bundles/settings.bundle.js deleted file mode 100644 index fff3760a..00000000 --- a/js/bundles/settings.bundle.js +++ /dev/null @@ -1,1554 +0,0 @@ -/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */ - -/* ===== js/components/DataIntegrityManager.js ===== */ -/** - * 数据完整性管理器 (仓库驱动版) - * 负责数据备份、验证、修复和导入导出功能 - * 基于统一的数据仓库接口执行原子操作 - */ -class DataIntegrityManager { - constructor(options = {}) { - this.backupInterval = 600000; // 10分钟自动备份 - this.maxBackups = 5; // 最多保留5个备份(减少占用) - this.dataVersion = '0.6.2-fix'; - this.backupTimer = null; - this.validationRules = new Map(); - this.repositories = null; - this.consistencyReport = null; - this.isInitialized = false; - this.registry = options.registry || window.StorageProviderRegistry || null; - this._unsubscribe = null; - - this.registerDefaultValidationRules(); - this.connectToProviders(); - - console.log('[DataIntegrityManager] 数据完整性管理器已创建'); - } - - connectToProviders() { - const registry = this.registry; - if (registry && typeof registry.onProvidersReady === 'function') { - this._unsubscribe = registry.onProvidersReady(({ repositories }) => { - this.attachRepositories(repositories); - }); - const current = registry.getCurrentProviders && registry.getCurrentProviders(); - if (current && current.repositories) { - this.attachRepositories(current.repositories); - } - return; - } - - if (window.dataRepositories) { - this.attachRepositories(window.dataRepositories); - return; - } - - console.warn('[DataIntegrityManager] 未检测到数据仓库注册表,等待外部注入'); - } - - async attachRepositories(repositories) { - if (!repositories) { - return; - } - if (this.repositories === repositories && this.isInitialized) { - return; - } - - this.repositories = repositories; - console.log('[DataIntegrityManager] 已绑定数据仓库接口'); - - try { - await this.initializeWithRepositories(); - } catch (error) { - console.error('[DataIntegrityManager] 初始化失败:', error); - this.startAutoBackup(); - this.isInitialized = true; - } - } - - async initializeWithRepositories() { - try { - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - - try { - this.consistencyReport = await this.repositories.runConsistencyChecks(); - console.log('[DataIntegrityManager] 初始一致性检查完成', this.consistencyReport); - } catch (reportError) { - console.warn('[DataIntegrityManager] 初始一致性检查失败:', reportError); - } - - this.startAutoBackup(); - try { await this.cleanupOldBackups(); } catch (_) {} - - this.isInitialized = true; - console.log('[DataIntegrityManager] 数据完整性管理器已初始化'); - } catch (error) { - throw error; - } - } - - _ensureInitialized() { - if (!this.isInitialized) { - console.warn('[DataIntegrityManager] 尚未完全初始化,使用降级模式'); - } - } - - async cleanupOldBackups() { - try { - if (!this.repositories) return; - const backups = await this.repositories.backups.list(); - if (backups.length <= this.maxBackups) return; - await this.repositories.backups.prune(this.maxBackups); - console.log('[DataIntegrityManager] 已执行备份裁剪'); - } catch (error) { - console.error('[DataIntegrityManager] 清理旧备份失败:', error); - } - } - - async createBackup(providedData, type = 'manual') { - let data = null; - try { - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - data = providedData || await this.getCriticalData(); - if (Object.keys(data).length === 0) { - throw new Error('无数据可备份'); - } - - // 统一经 BackupAPI(内部仍落 BackupRepository),保证 schema 与裁剪一致 - if (window.BackupAPI && typeof window.BackupAPI.create === 'function') { - const backupId = await window.BackupAPI.create({ - type, - data, - version: this.dataVersion - }); - const backupObj = await window.BackupAPI.getById(backupId); - console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${backupId}`); - return backupObj || { id: backupId, type, data, version: this.dataVersion }; - } - - const id = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const timestamp = new Date().toISOString(); - const backupObj = { - id, - timestamp, - data, - version: this.dataVersion, - type, - size: JSON.stringify(data).length - }; - await this.repositories.backups.add(backupObj); - console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${id}`); - return backupObj; - } catch (error) { - console.error('[DataIntegrityManager] 创建备份失败:', error); - if (error.name === 'QuotaExceededError' && data) { - this.exportDataAsFallback(data); - } - throw error; - } - } - - exportDataAsFallback(exportData) { - try { - const exportObj = { - exportDate: new Date().toISOString(), - version: this.dataVersion, - data: exportData, - note: 'Storage quota exceeded - manual backup' - }; - const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `ielts-data-backup-quota-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - console.log('[DataIntegrityManager] 配额溢出备份已下载'); - } catch (fallbackError) { - console.error('[DataIntegrityManager] fallback 导出失败:', fallbackError); - } - } - - registerDefaultValidationRules() { - this.validationRules.set('practice_records', { - required: ['id', 'startTime'], - types: { - id: 'string', - startTime: 'string', - endTime: 'string', - date: 'string', - duration: 'number', - examId: 'string', - examTitle: 'string', - scoreInfo: 'object' - }, - validators: { - startTime: (value) => !isNaN(new Date(value).getTime()), - date: (value) => !value || !isNaN(new Date(value).getTime()), - endTime: (value) => !value || !isNaN(new Date(value).getTime()), - duration: (value) => typeof value === 'number' && value >= 0, - id: (value) => typeof value === 'string' && value.length > 0 - } - }); - - this.validationRules.set('system_settings', { - types: { - theme: 'string', - language: 'string', - autoSave: 'boolean', - notifications: 'boolean' - } - }); - } - - startAutoBackup() { - if (this.backupTimer) { - clearInterval(this.backupTimer); - } - this.backupTimer = setInterval(() => { - this.performAutoBackup(); - }, this.backupInterval); - console.log(`[DataIntegrityManager] 自动备份已启动 (${this.backupInterval / 1000}秒间隔)`); - } - - stopAutoBackup() { - if (this.backupTimer) { - clearInterval(this.backupTimer); - this.backupTimer = null; - console.log('[DataIntegrityManager] 自动备份已停止'); - } - } - - async performAutoBackup() { - try { - const criticalData = await this.getCriticalData(); - if (Object.keys(criticalData).length > 0) { - await this.createBackup(criticalData, 'auto'); - console.log('[DataIntegrityManager] 自动备份完成'); - } else { - console.log('[DataIntegrityManager] 无关键数据需要备份'); - } - } catch (error) { - console.error('[DataIntegrityManager] 自动备份失败:', error); - } - } - - async getBackupList() { - try { - if (!this.repositories) return []; - const backups = await this.repositories.backups.list(); - return backups.map(b => ({ - id: b.id, - timestamp: b.timestamp, - type: b.type, - version: b.version, - size: b.size - })); - } catch (error) { - console.error('[DataIntegrityManager] 获取备份列表失败:', error); - return []; - } - } - - async restoreBackup(backupId) { - let currentSnapshot = null; - try { - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - - // 优先走 BackupAPI:统一还原 records/stats/exam_index/settings - if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') { - try { - currentSnapshot = await this.getCriticalData(); - } catch (snapshotError) { - console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError); - } - await window.BackupAPI.restore(backupId); - console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功 (BackupAPI)`); - return; - } - - const backup = await this.repositories.backups.getById(backupId); - if (!backup) { - throw new Error('备份不存在'); - } - try { - currentSnapshot = await this.getCriticalData(); - } catch (snapshotError) { - console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError); - } - const data = backup.data || {}; - // 缺失 practice_records 时不清空现有记录(settings-only 备份恢复不应删练习数据)。 - // 仅当备份显式包含 practice_records 数组时才恢复。 - const records = Array.isArray(data.practice_records) - ? data.practice_records - : (Array.isArray(data.practiceRecords) ? data.practiceRecords : null); - const stats = data.user_stats || data.userStats || null; - if (records != null) { - await this._restorePracticeRecords(records, stats); - } else if (stats) { - await this._writeUserStats(stats); - } - - if (data.system_settings && typeof data.system_settings === 'object') { - const currentSettings = await this.repositories.settings.getAll(); - const restoredSettings = { ...currentSettings, ...data.system_settings }; - await this.repositories.settings.saveAll(restoredSettings); - } - console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功`); - } catch (error) { - console.error('[DataIntegrityManager] 恢复备份失败:', error); - if (currentSnapshot) { - try { - await this._restoreFromBackup({ data: currentSnapshot }); - console.warn('[DataIntegrityManager] 恢复失败后已回滚到恢复前快照'); - } catch (restoreError) { - console.error('[DataIntegrityManager] 恢复失败后的回滚也失败:', restoreError); - } - } - throw error; - } - } - - async exportData() { - try { - const data = await this.getCriticalData(); - const exportObj = { - exportDate: new Date().toISOString(), - version: this.dataVersion, - data - }; - const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `ielts-data-backup-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - console.log('[DataIntegrityManager] 数据导出成功'); - } catch (error) { - console.error('[DataIntegrityManager] 导出数据失败:', error); - throw error; - } - } - - async importData(source, options = {}) { - this._ensureInitialized(); - if (!this.repositories) { - throw new Error('数据仓库不可用'); - } - - let payload; - let backup = null; - try { - payload = await this._normalizeImportPayload(source); - } catch (error) { - console.error('[DataIntegrityManager] 解析导入源失败:', error); - throw new Error(error?.message || '导入文件格式无效'); - } - - const hasPracticeSection = Array.isArray(payload.practice_records); - const hasSettingsSection = payload.system_settings && typeof payload.system_settings === 'object'; - const hasUserStatsSection = payload.user_stats && typeof payload.user_stats === 'object'; - const practiceRecords = hasPracticeSection ? this._preparePracticeRecords(payload.practice_records) : null; - const systemSettings = hasSettingsSection ? this._prepareSystemSettings(payload.system_settings) : {}; - const userStats = hasUserStatsSection ? payload.user_stats : null; - - if (!hasPracticeSection && !hasSettingsSection && !hasUserStatsSection) { - throw new Error('导入文件缺少可用的数据'); - } - - try { - backup = await this.createBackup(null, 'pre_import'); - } catch (error) { - console.warn('[DataIntegrityManager] 导入前创建备份失败:', error); - } - - try { - if (hasPracticeSection) { - await this._restorePracticeRecords(practiceRecords || [], userStats); - } else if (userStats) { - await this._writeUserStats(userStats); - } - - if (hasSettingsSection && Object.keys(systemSettings).length > 0) { - const current = await this.repositories.settings.getAll(); - const next = { ...current, ...systemSettings }; - await this.repositories.settings.saveAll(next); - } - } catch (error) { - console.error('[DataIntegrityManager] 导入数据失败:', error); - // 导入已部分写入:尝试从 pre_import 备份恢复,避免半导入状态损坏数据。 - if (backup && backup.id) { - try { - await this._restoreFromBackup(backup); - console.warn('[DataIntegrityManager] 导入失败后已从备份恢复:', backup.id); - } catch (restoreError) { - console.error('[DataIntegrityManager] 导入失败后恢复备份也失败:', restoreError); - } - } - throw new Error(error?.message || '导入数据失败'); - } - - return { - importedCount: practiceRecords ? practiceRecords.length : 0, - backupId: backup?.id || null, - version: payload.version || this.dataVersion - }; - } - - async getCriticalData() { - this._ensureInitialized(); - try { - if (!this.repositories) { - return {}; - } - const data = {}; - try { - const practiceRecords = await this._listPracticeRecords(); - // 读取失败时用 null 而非 [],区分"读取失败"与"确实无记录"。 - // rollback 时 null 表示不恢复 records,避免用空备份清空好数据。 - data.practice_records = practiceRecords != null ? practiceRecords : null; - } catch (recordsError) { - console.warn('[DataIntegrityManager] 获取练习记录失败:', recordsError); - data.practice_records = null; - } - - try { - const allSettings = await this.repositories.settings.getAll(); - const systemSettings = { - theme: allSettings.theme, - language: allSettings.language, - autoSave: allSettings.autoSave, - notifications: allSettings.notifications - }; - data.system_settings = systemSettings; - } catch (settingsError) { - console.warn('[DataIntegrityManager] 获取系统设置失败:', settingsError); - data.system_settings = {}; - } - - try { - const metaRepo = this.repositories.meta; - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - data.user_stats = await window.PracticeRecordAPI.readStats(); - } - if (metaRepo && typeof metaRepo.get === 'function') { - data.vocab_words = await metaRepo.get('vocab_words', []); - data.vocab_user_config = await metaRepo.get('vocab_user_config', null); - data.vocab_review_queue = await metaRepo.get('vocab_review_queue', []); - data.vocab_list_reading_highlights = await metaRepo.get('vocab_list_reading_highlights', []); - } - } catch (vocabError) { - console.warn('[DataIntegrityManager] 获取词汇数据失败:', vocabError); - } - - return data; - } catch (error) { - console.error('[DataIntegrityManager] 获取关键数据失败:', error); - return {}; - } - } - - async _listPracticeRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records : []; - } - if (this.repositories && this.repositories.practice && typeof this.repositories.practice.list === 'function') { - const records = await this.repositories.practice.list(); - return Array.isArray(records) ? records : []; - } - - return []; - } - - async _restorePracticeRecords(records, userStats = null) { - const finalRecords = Array.isArray(records) ? records : []; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') { - await window.PracticeRecordAPI.restoreRecords(finalRecords, { - stats: userStats && typeof userStats === 'object' ? userStats : null, - updateStats: true - }); - return true; - } - if (this.repositories && this.repositories.practice && typeof this.repositories.practice.overwrite === 'function') { - await this.repositories.practice.overwrite(finalRecords); - return true; - } - - throw new Error('统一练习记录恢复 API 未就绪'); - } - - async _writeUserStats(stats) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.writeStats === 'function') { - await window.PracticeRecordAPI.writeStats(stats); - return true; - } - if (this.repositories && this.repositories.meta && typeof this.repositories.meta.set === 'function') { - await this.repositories.meta.set('user_stats', stats); - return true; - } - - throw new Error('统一练习统计 API 未就绪'); - } - - // 导入失败时从 pre_import 备份恢复,避免数据停留在半导入状态。 - // 恢复失败仅记录,不掩盖原始导入错误。 - // 注意:practice_records 为 null/undefined 时不恢复 records(读取失败时的占位), - // 只有非 null 的数组才视为有效备份进行恢复;空数组也需恢复(表示备份时确实无记录)。 - async _restoreFromBackup(backup) { - if (!backup || !backup.data) { - return false; - } - const snapshot = backup.data; - const hasRecordsBackup = snapshot.practice_records != null; - const restoredRecords = hasRecordsBackup && Array.isArray(snapshot.practice_records) - ? this._preparePracticeRecords(snapshot.practice_records) - : null; - const restoredStats = snapshot.user_stats && typeof snapshot.user_stats === 'object' - ? snapshot.user_stats - : null; - if (restoredRecords) { - await this._restorePracticeRecords(restoredRecords, restoredStats); - } else if (restoredStats) { - await this._writeUserStats(restoredStats); - } - if (snapshot.system_settings && typeof snapshot.system_settings === 'object' - && Object.keys(snapshot.system_settings).length > 0) { - const current = await this.repositories.settings.getAll(); - const next = { ...current, ...snapshot.system_settings }; - await this.repositories.settings.saveAll(next); - } - return true; - } - - async _normalizeImportPayload(source) { - const raw = await this._resolveImportSource(source); - const container = this._unwrapDataSection(raw); - return { - practice_records: this._extractField(container, ['practice_records', 'practiceRecords', 'practice']), - system_settings: this._extractField(container, ['system_settings', 'systemSettings', 'settings']), - user_stats: this._extractField(container, ['user_stats', 'userStats']), - version: typeof raw?.version === 'string' ? raw.version : null - }; - } - - async _resolveImportSource(source) { - if (!source) { - throw new Error('未提供导入数据源'); - } - if (typeof source === 'string') { - return JSON.parse(source); - } - if (typeof Blob !== 'undefined' && source instanceof Blob && typeof source.text === 'function') { - const text = await source.text(); - return JSON.parse(text); - } - if (typeof File !== 'undefined' && source instanceof File) { - const text = await source.text(); - return JSON.parse(text); - } - if (source instanceof ArrayBuffer) { - const text = new TextDecoder('utf-8').decode(source); - return JSON.parse(text); - } - if (typeof source === 'object') { - return source; - } - throw new Error('不支持的导入数据类型'); - } - - _unwrapDataSection(raw) { - if (!raw || typeof raw !== 'object') { - throw new Error('导入文件格式无效'); - } - if (raw.data && typeof raw.data === 'object') { - return raw.data; - } - return raw; - } - - _extractField(container, variants) { - if (!container || typeof container !== 'object') { - return undefined; - } - const lookup = this._buildKeyLookup(container); - for (const variant of variants) { - if (lookup.has(variant.toLowerCase())) { - return lookup.get(variant.toLowerCase()); - } - } - return undefined; - } - - _buildKeyLookup(container) { - const map = new Map(); - Object.keys(container).forEach((key) => { - map.set(key.toLowerCase(), container[key]); - }); - return map; - } - - _preparePracticeRecords(list) { - if (!Array.isArray(list)) { - return []; - } - return list.filter(entry => entry && typeof entry === 'object'); - } - - _prepareSystemSettings(settings) { - if (!settings || typeof settings !== 'object') { - return {}; - } - const allowed = ['theme', 'language', 'autoSave', 'notifications']; - const prepared = {}; - for (const key of allowed) { - if (settings[key] !== undefined) { - prepared[key] = settings[key]; - } - } - return prepared; - } - -} - -let dataIntegrityManagerInstance = null; - -function getDataIntegrityManager() { - if (!dataIntegrityManagerInstance) { - dataIntegrityManagerInstance = new DataIntegrityManager(); - } - return dataIntegrityManagerInstance; -} - -if (typeof module !== 'undefined' && module.exports) { - module.exports = { DataIntegrityManager, getDataIntegrityManager }; -} else { - window.DataIntegrityManager = DataIntegrityManager; - window.getDataIntegrityManager = getDataIntegrityManager; -} - - -/* ===== js/utils/dataBackupManager.js ===== */ -/** - * Data backup and recovery manager. - * Provides export/import/cleanup functionality for the shared storage layer. - */ -class DataBackupManager { - constructor() { - this.storageKeys = { - backupSettings: 'backup_settings', - exportHistory: 'export_history', - importHistory: 'import_history', - manualBackups: 'manual_backups' - }; - - this.supportedFormats = ['json', 'csv']; - this.maxBackupHistory = 20; - this.maxExportHistory = 50; - - this.initialize(); - } - - sanitizeExamTitle(title) { - if (!title) return ''; - const str = String(title).trim(); - if (!str) return ''; - const pattern = /ielts\s+listening\s+practice\s*-\s*part\s*\d+\s*[:\-]?\s*(.+)$/i; - const match = str.match(pattern); - if (match && match[1]) { - return match[1].trim(); - } - if (str.includes(' - ')) { - const segments = str.split(' - ').map((s) => s.trim()).filter(Boolean); - if (segments.length > 1) { - return segments[segments.length - 1]; - } - } - return str; - } - - sanitizeRecord(record) { - if (!record || typeof record !== 'object') { - return record; - } - const clone = { ...record }; - const metadata = (clone.metadata && typeof clone.metadata === 'object') ? { ...clone.metadata } : {}; - const baseTitle = metadata.examTitle || metadata.title || clone.title || clone.examTitle; - const cleanedTitle = this.sanitizeExamTitle(baseTitle); - if (cleanedTitle) { - metadata.examTitle = cleanedTitle; - metadata.title = metadata.title || cleanedTitle; - clone.title = cleanedTitle; - if (!clone.examTitle) { - clone.examTitle = cleanedTitle; - } - clone.metadata = metadata; - } - return clone; - } - - async initialize() { - try { - await this.initializeSettings(); - } catch (error) { - console.error('[DataBackupManager] failed to initialize settings', error); - } - - this.setupPeriodicCleanup(); - } - - async initializeSettings() { - const defaults = { - autoBackup: true, - backupInterval: 24, - maxBackups: 10, - compressionEnabled: false, - encryptionEnabled: false, - lastAutoBackup: null - }; - - try { - const stored = await storage.get(this.storageKeys.backupSettings, defaults); - await storage.set(this.storageKeys.backupSettings, { ...defaults, ...stored }); - } catch (error) { - console.error('[DataBackupManager] unable to persist settings', error); - } - } - - async listPracticeRecords() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - return await window.PracticeRecordAPI.list(); - } - - throw new Error('统一练习记录存储未就绪'); - } - - async replacePracticeRecords(records, options = {}) { - const normalizedRecords = Array.isArray(records) ? records : []; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') { - await window.PracticeRecordAPI.replace(normalizedRecords, options); - return true; - } - - throw new Error('统一练习记录存储未就绪'); - } - - async restorePracticeRecords(records, stats = null) { - const normalizedRecords = Array.isArray(records) ? records : []; - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') { - return await window.PracticeRecordAPI.restoreRecords(normalizedRecords, { - stats: this.isPlainObject(stats) ? stats : null, - updateStats: true - }); - } - - throw new Error('统一练习记录恢复 API 未就绪'); - } - - async readUserStats() { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') { - return await window.PracticeRecordAPI.readStats(); - } - - throw new Error('统一练习统计 API 未就绪'); - } - - async mergeUserStats(stats, mergeMode = 'merge') { - if (!this.isPlainObject(stats)) { - return await this.readUserStats(); - } - - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeStats === 'function') { - return await window.PracticeRecordAPI.mergeStats(stats, { mergeMode }); - } - - throw new Error('统一练习统计 API 未就绪'); - } - - async resetUserStats(stats = null) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.resetStats === 'function') { - return await window.PracticeRecordAPI.resetStats(stats); - } - - throw new Error('统一练习统计 API 未就绪'); - } - - async createBackup(backupName = null, type = 'manual') { - if (window.BackupAPI && typeof window.BackupAPI.create === 'function') { - return await window.BackupAPI.create({ - id: backupName || undefined, - type - }); - } - - // Fallback when BackupAPI not loaded yet (early boot / isolated tests) - const practiceRecords = await this.listPracticeRecords(); - const userStats = await this.readUserStats(); - const examIndex = await storage.get('exam_index', []); - const backup = { - id: backupName || `backup_${Date.now()}`, - timestamp: new Date().toISOString(), - type, - data: { - practice_records: practiceRecords, - practiceRecords, - user_stats: userStats, - userStats, - exam_index: examIndex, - examIndex - } - }; - - const backups = await storage.get(this.storageKeys.manualBackups, []); - backups.unshift(backup); - while (backups.length > this.maxBackupHistory) { - backups.pop(); - } - await storage.set(this.storageKeys.manualBackups, backups); - return backup.id; - } - - async exportPracticeRecords(options = {}) { - const { - format = 'json', - includeStats = true, - includeBackups = false, - dateRange = null, - categories = null, - compression = false - } = options; - - const normalizedFormat = String(format).toLowerCase(); - if (!this.supportedFormats.includes(normalizedFormat)) { - throw new Error(`Unsupported export format: ${format}`); - } - - let practiceRecords = await this.listPracticeRecords(); - practiceRecords = Array.isArray(practiceRecords) ? practiceRecords : []; - - if (dateRange) { - practiceRecords = this.filterByDateRange(practiceRecords, dateRange); - } - - if (Array.isArray(categories) && categories.length) { - practiceRecords = practiceRecords.filter(record => categories.includes(record?.metadata?.category)); - } - - const exportPayload = { - exportInfo: { - timestamp: new Date().toISOString(), - version: '0.6.2-fix', - format: normalizedFormat, - recordCount: practiceRecords.length, - options: { format, includeStats, includeBackups, dateRange, categories } - }, - practiceRecords - }; - - if (includeStats) { - exportPayload.userStats = await this.readUserStats(); - } - - if (includeBackups) { - try { - // 统一经 BackupAPI 读全量列表;不再经 scoreStorage 的类型过滤旁路 - if (window.BackupAPI && typeof window.BackupAPI.list === 'function') { - exportPayload.backups = await window.BackupAPI.list(); - } else { - exportPayload.backups = await storage.get(this.storageKeys.manualBackups, []); - } - if (!Array.isArray(exportPayload.backups)) { - exportPayload.backups = []; - } - } catch (error) { - console.warn('[DataBackupManager] failed to include backups in export', error); - exportPayload.backups = []; - } - } - - await this.recordExportHistory(exportPayload.exportInfo); - - switch (normalizedFormat) { - case 'json': - return this.exportAsJSON(exportPayload, compression); - case 'csv': - return this.exportAsCSV(exportPayload); - default: - throw new Error(`Format ${format} not implemented`); - } - } - - exportAsJSON(data, compressionEnabled = false) { - const raw = JSON.stringify(data, null, 2); - const payload = compressionEnabled ? this.compressData(raw) : raw; - - return { - data: payload, - filename: `practice_records_${this.getTimestamp()}.json`, - mimeType: 'application/json', - size: payload.length, - compressed: compressionEnabled - }; - } - - exportAsCSV(data) { - const records = Array.isArray(data.practiceRecords) ? data.practiceRecords : []; - const headers = [ - 'record_id', - 'exam_id', - 'title', - 'status', - 'score', - 'accuracy', - 'duration_seconds', - 'start_time', - 'end_time', - 'category', - 'frequency', - 'created_at' - ]; - - const rows = records.map(record => { - const metadata = record?.metadata || {}; - return [ - record?.id ?? '', - record?.examId ?? '', - record?.title ?? '', - record?.status ?? '', - record?.score ?? '', - record?.accuracy ?? '', - record?.duration ?? '', - record?.startTime ?? '', - record?.endTime ?? '', - metadata.category ?? '', - metadata.frequency ?? '', - record?.createdAt ?? '' - ]; - }); - - const csvContent = [headers, ...rows] - .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')) - .join('\n'); - - return { - data: csvContent, - filename: `practice_records_${this.getTimestamp()}.csv`, - mimeType: 'text/csv', - size: csvContent.length - }; - } - /** - * Legacy-friendly wrapper. - */ - async importPracticeRecords(source, options = {}) { - return this.importPracticeData(source, options); - } - - async importPracticeData(source, options = {}) { - console.log('[DataBackupManager] importPracticeData called, source type:', typeof source, 'length:', Array.isArray(source) ? source.length : source.practiceRecords?.length); - const { - mergeMode = 'merge', - createBackup = true, - preserveIds = true - } = options; - - let payload; - try { - payload = await this.parseImportSource(source, { allowFetch: true }); - } catch (error) { - throw new Error(`Failed to read import source: ${error.message}`); - } - - const normalized = this.normalizeImportPayload(payload, { preserveIds }); - console.log('[DataBackupManager] Normalized records:', normalized.practiceRecords.length); - - let practiceRecords = Array.isArray(normalized.practiceRecords) ? normalized.practiceRecords : []; - - if (!practiceRecords.length) { - throw new Error('Import file does not contain any practice records.'); - } - - practiceRecords = practiceRecords.map((r) => this.sanitizeRecord(r)); - normalized.practiceRecords = practiceRecords; - console.log('[DataBackupManager] After sanitize, records:', normalized.practiceRecords.length); - - let backupId = null; - if (createBackup) { - backupId = await this.createPreImportBackup(); - console.log('[DataBackupManager] Pre-import backup created:', backupId); - } - - let mergeResult; - try { - // 若备份同时携带 user_stats,导入 records 时禁止并发 recalculateStats, - // 否则会与后续 mergeUserStats 竞态,覆盖备份中的 practiceDays/streakDays 等字段。 - const hasImportedStats = Boolean(normalized.userStats); - mergeResult = await this.mergePracticeRecords( - normalized.practiceRecords, - mergeMode, - { updateStats: !hasImportedStats } - ); - console.log('[DataBackupManager] Practice records imported through PracticeRecordAPI'); - - if (normalized.userStats) { - await this.mergeUserStats(normalized.userStats, mergeMode); - } - } catch (error) { - if (backupId) { - try { - await this.restoreBackup(backupId); - } catch (restoreError) { - console.error('[DataBackupManager] failed to restore backup after import error', restoreError); - } - } - - await this.recordImportHistory({ - timestamp: new Date().toISOString(), - mergeMode, - backupId, - success: false, - error: error.message - }); - throw error; - } - - await this.recordImportHistory({ - timestamp: new Date().toISOString(), - recordCount: mergeResult.importedCount, - mergeMode, - backupId, - sources: normalized.sources, - success: true - }); - - return { - success: true, - ...mergeResult, - backupId, - statsImported: Boolean(normalized.userStats), - sources: normalized.sources - }; - } - - async parseImportSource(source, { allowFetch = false } = {}) { - if (source === undefined || source === null) { - throw new Error('Import source is empty.'); - } - - if (typeof File !== 'undefined' && source instanceof File) { - return this.parseImportSource(await source.text(), { allowFetch }); - } - - if (typeof Blob !== 'undefined' && source instanceof Blob) { - return this.parseImportSource(await source.text(), { allowFetch }); - } - - if (typeof source === 'string') { - const trimmed = source.trim(); - if (!trimmed) { - throw new Error('Import source string is empty.'); - } - - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - try { - return JSON.parse(trimmed); - } catch (error) { - throw new Error('Import string is not valid JSON.'); - } - } - - if (!allowFetch) { - throw new Error('Import string is neither JSON nor a fetchable path.'); - } - - const response = await fetch(trimmed); - if (!response.ok) { - throw new Error(`Failed to fetch import file: ${response.status}`); - } - return await response.json(); - } - - if (Array.isArray(source) || this.isPlainObject(source)) { - return source; - } - - throw new Error('Unsupported import source type.'); - } - - normalizeImportPayload(payload, { preserveIds = true } = {}) { - if (payload === undefined || payload === null) { - throw new Error('Import data is empty.'); - } - - const practiceRecords = []; - const sources = []; - let userStats = null; - - if (this.isPlainObject(payload)) { - const directStats = payload.user_stats - ?? payload.userStats - ?? payload.stats - ?? payload.data?.user_stats - ?? payload.data?.userStats - ?? payload.data?.stats; - if (this.isPlainObject(directStats)) { - userStats = this.prepareUserStats(directStats); - } - } - - this.extractRecordSources(payload).forEach(({ records, source }) => { - const normalizedRecords = records - .map((record, index) => this.normalizeRecord(record, { - preserveIds, - fallbackIdPrefix: source || 'record', - index - })) - .filter(Boolean); - - if (normalizedRecords.length) { - practiceRecords.push(...normalizedRecords); - sources.push({ path: source || '(root array)', count: normalizedRecords.length }); - } - }); - - // Dual-schema payloads and multi-path recovery can surface the same id twice; - // keep first occurrence so replace-mode import does not invent duplicates. - const seenIds = new Set(); - const dedupedPracticeRecords = []; - practiceRecords.forEach((record) => { - if (!record || typeof record !== 'object') { - return; - } - const id = record.id != null ? String(record.id) : null; - if (id) { - if (seenIds.has(id)) { - return; - } - seenIds.add(id); - } - dedupedPracticeRecords.push(record); - }); - - return { - practiceRecords: dedupedPracticeRecords, - userStats, - sources - }; - } - - extractRecordSources(payload) { - const sources = []; - const add = (source, records) => { - if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) { - sources.push({ source, records }); - } - }; - // App backups write dual aliases (practice_records + practiceRecords) for the same list. - // Prefer the first non-empty array so replace-mode import does not double-append. - const addPreferred = (candidates) => { - for (const { source, records } of candidates) { - if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) { - add(source, records); - return true; - } - } - return false; - }; - - if (Array.isArray(payload)) { - add('(root array)', payload); - return sources; - } - if (!this.isPlainObject(payload)) { - return sources; - } - - addPreferred([ - { source: 'practice_records', records: payload.practice_records }, - { source: 'practiceRecords', records: payload.practiceRecords } - ]); - - const data = this.isPlainObject(payload.data) ? payload.data : {}; - const dataArrayPicked = addPreferred([ - { source: 'data.practice_records', records: data.practice_records }, - { source: 'data.practiceRecords', records: data.practiceRecords } - ]); - // Envelope form only when the preferred alias was not already a plain array source. - if (!dataArrayPicked && this.isPlainObject(data.practice_records)) { - add('data.practice_records.data', data.practice_records.data); - } else if (!dataArrayPicked && this.isPlainObject(data.practiceRecords)) { - add('data.practiceRecords.data', data.practiceRecords.data); - } - if (this.isPlainObject(data.exam_system_practice_records)) { - add('data.exam_system_practice_records.data', data.exam_system_practice_records.data); - } - if (this.isPlainObject(payload.exam_system_practice_records)) { - add('exam_system_practice_records.data', payload.exam_system_practice_records.data); - } - - return sources; - } - - async mergePracticeRecords(newRecords, mergeMode = 'merge', options = {}) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeRecords === 'function') { - return await window.PracticeRecordAPI.mergeRecords( - Array.isArray(newRecords) ? newRecords : [], - { - mergeMode, - updateStats: options.updateStats !== false - } - ); - } - - throw new Error('统一练习记录导入 API 未就绪'); - } - - prepareUserStats(candidate) { - if (!this.isPlainObject(candidate)) { - return null; - } - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.prepareStats === 'function') { - return window.PracticeRecordAPI.prepareStats(candidate); - } - throw new Error('统一练习统计 API 未就绪'); - } - - normalizeRecord(record, options = {}) { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.normalizeRecord === 'function') { - return window.PracticeRecordAPI.normalizeRecord(record, options); - } - throw new Error('统一练习记录标准化 API 未就绪'); - } - normalizeDateValue(value) { - if (!value) { - return null; - } - - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString(); - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - if (/^\d+$/.test(trimmed)) { - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000; - return new Date(milliseconds).toISOString(); - } - } - - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - - return null; - } - - getRecordTimestamp(record) { - if (!record) { - return 0; - } - - const candidates = [ - record.updatedAt, - record.createdAt, - record.endTime, - record.startTime, - record.timestamp, - record.date - ]; - - for (const candidate of candidates) { - const iso = this.normalizeDateValue(candidate); - if (iso) { - const time = new Date(iso).getTime(); - if (Number.isFinite(time)) { - return time; - } - } - } - - return 0; - } - - filterByDateRange(records, dateRange) { - const { startDate, endDate } = dateRange; - return (records || []).filter(record => { - const value = this.normalizeDateValue(record?.startTime ?? record?.createdAt ?? record?.timestamp); - if (!value) { - return false; - } - - const recordDate = new Date(value); - if (startDate && recordDate < new Date(startDate)) { - return false; - } - if (endDate && recordDate > new Date(endDate)) { - return false; - } - return true; - }); - } - - compressData(data) { - try { - if (window.pako && typeof window.pako.gzip === 'function') { - return window.pako.gzip(data, { to: 'string' }); - } - } catch (error) { - console.warn('[DataBackupManager] compression failed', error); - } - return data; - } - async createPreImportBackup() { - try { - // 与 createBackup 共用 unshift + pop 裁剪,避免 push+shift 误删最新用户备份 - return await this.createBackup(`pre_import_${Date.now()}`, 'pre_import'); - } catch (error) { - console.error('[DataBackupManager] failed to create backup', error); - return null; - } - } - - async restoreBackup(backupId) { - if (!backupId) { - throw new Error('Invalid backup id.'); - } - - try { - if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') { - const result = await window.BackupAPI.restore(backupId); - return result.backup; - } - - const backups = await storage.get(this.storageKeys.manualBackups, []); - const backup = backups.find(item => item.id === backupId); - if (!backup) { - throw new Error(`Backup ${backupId} not found.`); - } - - const data = backup.data || {}; - const records = Array.isArray(data.practice_records) - ? data.practice_records - : (Array.isArray(data.practiceRecords) ? data.practiceRecords : []); - const stats = this.isPlainObject(data.user_stats) - ? data.user_stats - : (this.isPlainObject(data.userStats) ? data.userStats : null); - - await this.restorePracticeRecords(records, stats); - - const examIndex = Array.isArray(data.exam_index) - ? data.exam_index - : (Array.isArray(data.examIndex) ? data.examIndex : null); - if (examIndex) { - await storage.set('exam_index', examIndex); - } - - return backup; - } catch (error) { - console.error('[DataBackupManager] backup restore failed', error); - throw error; - } - } - - async clearData(options = {}) { - const { - clearPracticeRecords = false, - clearUserStats = false, - clearBackups = false, - clearSettings = false, - createBackup = true - } = options; - - let backupId = null; - if (createBackup) { - backupId = await this.createPreImportBackup(); - } - - const clearedItems = []; - - if (clearPracticeRecords) { - await this.replacePracticeRecords([], { updateStats: !clearUserStats }); - clearedItems.push('practice_records'); - if (!clearUserStats) { - clearedItems.push('user_stats'); - } - } - - if (clearUserStats) { - await this.resetUserStats(); - clearedItems.push('user_stats'); - } - - if (clearBackups) { - if (window.BackupAPI && typeof window.BackupAPI.clear === 'function') { - await window.BackupAPI.clear(); - } else { - await storage.set(this.storageKeys.manualBackups, []); - } - if (typeof storage.remove === 'function') { - await storage.remove('backup_data'); - } - clearedItems.push('backups'); - } - - if (clearSettings) { - await storage.remove('settings'); - await storage.remove(this.storageKeys.backupSettings); - clearedItems.push('settings'); - } - - return { - success: true, - clearedItems, - backupId - }; - } - - async recordExportHistory(info) { - const history = await storage.get(this.storageKeys.exportHistory, []); - history.push({ ...info, id: `export_${Date.now()}` }); - while (history.length > this.maxExportHistory) { - history.shift(); - } - await storage.set(this.storageKeys.exportHistory, history); - } - - async recordImportHistory(info) { - const history = await storage.get(this.storageKeys.importHistory, []); - history.push({ ...info, id: `import_${Date.now()}` }); - while (history.length > this.maxExportHistory) { - history.shift(); - } - await storage.set(this.storageKeys.importHistory, history); - } - - async getExportHistory() { - const history = await storage.get(this.storageKeys.exportHistory, []); - return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } - - async getImportHistory() { - const history = await storage.get(this.storageKeys.importHistory, []); - return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - } - async getDataStats() { - try { - const practiceRecords = await this.listPracticeRecords(); - const userStats = await this.readUserStats(); - const exportHistory = await this.getExportHistory(); - const importHistory = await this.getImportHistory(); - const storageInfo = typeof storage.getStorageInfo === 'function' ? await storage.getStorageInfo() : null; - - const recordsArray = Array.isArray(practiceRecords) ? practiceRecords : []; - - return { - practiceRecords: { - count: recordsArray.length, - oldestRecord: recordsArray.length ? recordsArray[0]?.startTime : null, - newestRecord: recordsArray.length ? recordsArray[recordsArray.length - 1]?.startTime : null - }, - userStats: { - totalPractices: userStats?.totalPractices ?? 0, - totalTimeSpent: userStats?.totalTimeSpent ?? 0, - averageScore: userStats?.averageScore ?? 0 - }, - exportHistory: { - count: exportHistory.length, - lastExport: exportHistory.length ? exportHistory[0].timestamp : null - }, - importHistory: { - count: importHistory.length, - lastImport: importHistory.length ? importHistory[0].timestamp : null - }, - storage: storageInfo - }; - } catch (error) { - console.error('[DataBackupManager] failed to collect stats', error); - return null; - } - } - - setupPeriodicCleanup() { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - } - - this.cleanupTimer = setInterval(() => { - this.cleanupExpiredData().catch(error => console.error('[DataBackupManager] cleanup failed', error)); - }, 24 * 60 * 60 * 1000); - } - - async cleanupExpiredData() { - try { - const limit = 30 * 24 * 60 * 60 * 1000; - const now = Date.now(); - - const exportHistory = await storage.get(this.storageKeys.exportHistory, []); - const freshExports = exportHistory.filter(item => now - new Date(item.timestamp).getTime() < limit); - if (freshExports.length !== exportHistory.length) { - await storage.set(this.storageKeys.exportHistory, freshExports); - } - - const importHistory = await storage.get(this.storageKeys.importHistory, []); - const freshImports = importHistory.filter(item => now - new Date(item.timestamp).getTime() < limit); - if (freshImports.length !== importHistory.length) { - await storage.set(this.storageKeys.importHistory, freshImports); - } - } catch (error) { - console.error('[DataBackupManager] cleanup error', error); - } - } - - getTimestamp() { - return new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); - } - - toCamelCaseKey(key) { - return String(key) - .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase()) - .replace(/^[A-Z]/, match => match.toLowerCase()); - } - - isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]'; - } -} - -window.DataBackupManager = DataBackupManager; - - -/* ===== bundle provided script markers ===== */ -(function markBundleProvided(global) { - if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") { - global.AppLazyLoader.markProvided([ - "js/components/DataIntegrityManager.js", - "js/utils/dataBackupManager.js" -]); - } -})(typeof window !== "undefined" ? window : this); diff --git a/js/bundles/theme.bundle.js b/js/bundles/theme.bundle.js index b914c8e4..7ae4f007 100644 --- a/js/bundles/theme.bundle.js +++ b/js/bundles/theme.bundle.js @@ -1,53 +1,36 @@ /* Generated by scripts/build-bundles.mjs. Do not edit by hand. */ /* ===== js/theme-switcher.js ===== */ -const THEME_PORTAL_STORAGE_KEY = 'preferred_theme_portal'; -const THEME_PORTAL_SESSION_SKIP_KEY = 'preferred_theme_skip_session'; - -function safeParse(json) { - if (!json) { - return null; - } - try { - const value = JSON.parse(json); - return value && typeof value === 'object' ? value : null; - } catch (error) { - console.warn('[Theme] 无法解析主题首选项:', error); - return null; - } -} - const themePreferenceController = { - STORAGE_KEY: THEME_PORTAL_STORAGE_KEY, - SESSION_KEY: THEME_PORTAL_SESSION_SKIP_KEY, + cache: null, + ready: null, load() { - try { - return safeParse(localStorage.getItem(this.STORAGE_KEY)); - } catch (error) { - console.warn('[Theme] 读取主题首选项失败:', error); - return null; + return this.cache; + }, + + hydrate() { + if (!this.ready) { + this.ready = window.AppData.ready.then(() => window.AppData.preferences.getThemePortal()).then((value) => { + this.cache = value; + return value; + }); } + return this.ready; }, - save(payload) { + async save(payload) { if (!payload || typeof payload !== 'object') { - this.clear(); - return; - } - try { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(payload)); - } catch (error) { - console.warn('[Theme] 保存主题首选项失败:', error); + return this.clear(); } + await window.AppData.preferences.setThemePortal(payload); + this.cache = payload; + return payload; }, - clear() { - try { - localStorage.removeItem(this.STORAGE_KEY); - } catch (_) { - // no-op - } + async clear() { + await window.AppData.preferences.setThemePortal(null); + this.cache = null; }, recordInternalTheme(themeId = 'default') { @@ -56,8 +39,8 @@ const themePreferenceController = { theme: themeId, updatedAt: Date.now() }; - this.save(snapshot); - return this.load(); + this.save(snapshot).catch((error) => console.warn('[Theme] 保存主题首选项失败:', error)); + return snapshot; } }; @@ -71,7 +54,7 @@ function applyTheme(theme) { if (!theme) return; try { root.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme); + window.AppData.preferences.setTheme(theme).catch((error) => console.warn('[Theme] 保存主题失败:', error)); themePreferenceController.recordInternalTheme(theme); } catch (e) {} } @@ -80,7 +63,7 @@ function applyDefaultTheme() { const root = document.documentElement; try { root.removeAttribute('data-theme'); - localStorage.removeItem('theme'); + window.AppData.preferences.setTheme('default').catch((error) => console.warn('[Theme] 保存主题失败:', error)); themePreferenceController.recordInternalTheme('default'); } catch (e) {} } @@ -148,7 +131,7 @@ function initializeThemeScrollerControls() { syncThemeScrollerButtons(); } -function initializeThemeSwitcher() { +async function initializeThemeSwitcher() { if (typeof window !== 'undefined' && window.__themeSwitcherInitialized) { return; } @@ -158,10 +141,12 @@ function initializeThemeSwitcher() { window.__syncThemeScrollerButtons = syncThemeScrollerButtons; } - // Restore general theme try { - const savedTheme = localStorage.getItem('theme'); - if (savedTheme) applyTheme(savedTheme); + await window.AppData.ready; + await themePreferenceController.hydrate(); + const savedTheme = await window.AppData.preferences.getTheme(); + if (savedTheme && savedTheme !== 'default') document.documentElement.setAttribute('data-theme', savedTheme); + else document.documentElement.removeAttribute('data-theme'); } catch (e) {} // Close modal when clicking outside diff --git a/js/bundles/ui-shell.bundle.js b/js/bundles/ui-shell.bundle.js index 650a0d8e..1da88d56 100644 --- a/js/bundles/ui-shell.bundle.js +++ b/js/bundles/ui-shell.bundle.js @@ -1052,8 +1052,6 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 (function initPracticeTimerPreferences(global) { 'use strict'; - var READING_KEY = 'ielts_reading_timer_preferences_v2'; - var LISTENING_KEY = 'ielts_listening_timer_preferences_v1'; var VERSION = 1; var DEFAULTS = { version: VERSION, @@ -1090,26 +1088,38 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 }; } - function keyFor(scope) { - return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY; + var cache = Object.create(null); + var hydrationPromise = null; + function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; } + function hydrateTimerPreferences() { + if (cache.reading && cache.listening) return Promise.resolve(true); + if (hydrationPromise) return hydrationPromise; + if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false); + hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getTimer(); + cache.reading = normalize(stored && stored.reading); + cache.listening = normalize(stored && stored.listening); + return true; + }).catch(function onTimerPreferenceLoadError(error) { + hydrationPromise = null; + console.warn('[PracticeTimerPreferences] 加载失败:', error); + return false; + }); + return hydrationPromise; } function read(scope) { - try { - var raw = global.localStorage && global.localStorage.getItem(keyFor(scope)); - return normalize(raw ? JSON.parse(raw) : null); - } catch (_) { - return normalize(null); - } + return normalize(cache[normalizeScope(scope)]); } - function save(scope, preferences) { + async function save(scope, preferences) { + await hydrateTimerPreferences(); + if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable'); + var normalizedScope = normalizeScope(scope); var next = normalize(preferences); - try { - if (global.localStorage) { - global.localStorage.setItem(keyFor(scope), JSON.stringify(next)); - } - } catch (_) { } + await global.AppData.preferences.setTimer(normalizedScope, next); + cache[normalizedScope] = next; return next; } @@ -1117,17 +1127,16 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 return clampMinutes(value, DEFAULTS.countdownMinutes) * 60; } - global.PracticeTimerPreferences = { + var api = { VERSION: VERSION, - READING_KEY: READING_KEY, - LISTENING_KEY: LISTENING_KEY, DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)), normalize: normalize, read: read, save: save, - keyFor: keyFor, minutesToSeconds: minutesToSeconds }; + Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences }); + global.PracticeTimerPreferences = api; })(typeof window !== 'undefined' ? window : globalThis); @@ -1455,8 +1464,9 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 var SESSION_GROUP = 'session-suite'; var STATE_CORE_GROUP = 'state-core'; var SETTINGS_GROUP = 'settings-tools'; - var READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1'; var READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/; + var readingCandidateCodeCache = { mode: 'auto', customCode: '' }; + var readingCandidateCodeReady = null; function ensureLazyGroup(name) { if (!name || !global.AppLazyLoader || typeof global.AppLazyLoader.ensureGroup !== 'function') { @@ -1494,34 +1504,32 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 } function readReadingCandidateCodePreferences() { - try { - var raw = global.localStorage && global.localStorage.getItem(READING_CANDIDATE_CODE_PREF_KEY); - var parsed = raw ? JSON.parse(raw) : null; - var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto'; - var customCode = parsed && typeof parsed.customCode === 'string' - ? parsed.customCode.replace(/\D/g, '').slice(0, 6) - : ''; - return { - mode: mode, - customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' - }; - } catch (_) { - return { mode: 'auto', customCode: '' }; - } + return Object.assign({}, readingCandidateCodeCache); + } + + function loadReadingCandidateCodePreferences() { + if (readingCandidateCodeReady) return readingCandidateCodeReady; + readingCandidateCodeReady = Promise.resolve().then(async function loadCandidateCode() { + await global.AppData.ready; + var stored = await global.AppData.preferences.getCandidateCode(); + var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto'; + var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : ''; + readingCandidateCodeCache = { mode: mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' }; + return readingCandidateCodeCache; + }); + return readingCandidateCodeReady; } - function saveReadingCandidateCodePreferences(preferences) { + async function saveReadingCandidateCodePreferences(preferences) { + await loadReadingCandidateCodePreferences(); var next = { mode: preferences && preferences.mode === 'custom' ? 'custom' : 'auto', customCode: preferences && typeof preferences.customCode === 'string' ? preferences.customCode.replace(/\D/g, '').slice(0, 6) : '' }; - try { - if (global.localStorage) { - global.localStorage.setItem(READING_CANDIDATE_CODE_PREF_KEY, JSON.stringify(next)); - } - } catch (_) { } + await global.AppData.preferences.setCandidateCode(next); + readingCandidateCodeCache = next; return next; } @@ -1537,7 +1545,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 } } - function setupReadingCandidateCodeSettings() { + async function setupReadingCandidateCodeSettings() { + await loadReadingCandidateCodePreferences(); var input = document.getElementById('reading-candidate-code-input'); var saveButton = document.getElementById('reading-candidate-code-save-btn'); var randomButton = document.getElementById('reading-candidate-code-random-btn'); @@ -1592,7 +1601,7 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 setReadingCandidateCodeStatus(status, '', ''); }); - saveButton.addEventListener('click', function saveCandidateCodeSettings() { + saveButton.addEventListener('click', async function saveCandidateCodeSettings() { var mode = getSelectedMode(); var code = input.value.replace(/\D/g, '').slice(0, 6); if (mode === 'custom' && !READING_CANDIDATE_CODE_PATTERN.test(code)) { @@ -1600,7 +1609,7 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 input.focus(); return; } - saveReadingCandidateCodePreferences({ mode: mode, customCode: code }); + await saveReadingCandidateCodePreferences({ mode: mode, customCode: code }); setReadingCandidateCodeStatus( status, mode === 'custom' ? '已保存自定义编码:' + code : '已保存:自动生成。', @@ -1608,11 +1617,11 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 ); }); - randomButton.addEventListener('click', function generateCandidateCode() { + randomButton.addEventListener('click', async function generateCandidateCode() { var code = hashReadingCandidateCode(createReadingCandidateCodeSeed()); setSelectedMode('custom'); input.value = code; - saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code }); + await saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code }); setReadingCandidateCodeStatus(status, '已随机生成并保存:' + code, 'success'); }); @@ -1631,12 +1640,13 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 } } - function setupPracticeTimerSettings() { + async function setupPracticeTimerSettings() { var manager = global.PracticeTimerPreferences; if (!manager || typeof manager.read !== 'function' || typeof manager.save !== 'function') { return; } + if (manager.ready) await manager.ready; Array.prototype.slice.call(document.querySelectorAll('.practice-timer-card[data-timer-scope]')) .forEach(function bindTimerCard(card) { var scope = String(card.dataset.timerScope || '').toLowerCase() === 'listening' @@ -1692,10 +1702,14 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 setPracticeTimerStatus(status, '', ''); }); }); - saveButton.addEventListener('click', function saveTimerPreferences() { - var saved = manager.save(scope, collect()); - apply(saved); - setPracticeTimerStatus(status, '已保存', 'success'); + saveButton.addEventListener('click', async function saveTimerPreferences() { + try { + var saved = await manager.save(scope, collect()); + apply(saved); + setPracticeTimerStatus(status, '已保存', 'success'); + } catch (error) { + setPracticeTimerStatus(status, '保存失败', 'error'); + } }); apply(manager.read(scope)); @@ -1791,20 +1805,6 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 return ensureLazyGroup(SETTINGS_GROUP); } - function setStorageNamespace() { - if (!global.storage || !global.storage.ready || typeof global.storage.setNamespace !== 'function') { - return; - } - global.storage.ready.then(function applyNamespace() { - global.storage.setNamespace('exam_system'); - try { - console.log('[MainEntry] 已设置存储命名空间: exam_system'); - } catch (_) { } - }).catch(function handleNamespaceError(error) { - console.error('[MainEntry] 设置命名空间失败', error); - }); - } - function initializeNavigationShell() { try { if (global.NavigationController && typeof global.NavigationController.ensure === 'function') { @@ -2042,35 +2042,29 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 return active.id.replace(/-view$/, ''); } - function syncOverviewAfterIndexLoad() { - if (!global.app || typeof global.app.setState !== 'function') { - return; - } - if (typeof global.getExamIndexState !== 'function') { - return; - } - var list = global.getExamIndexState(); + function syncOverviewAfterIndexLoad(index) { + var list = Array.isArray(index) ? index : []; if (!Array.isArray(list)) { return; } try { - global.app.setState('exam.index', list.slice()); - if (typeof global.app.refreshOverviewData === 'function') { - global.app.refreshOverviewData(); + if (typeof global.updateOverview === 'function') { + global.updateOverview(list); } } catch (error) { console.warn('[MainEntry] 同步总览数据失败:', error); } } - function handleExamIndexLoaded() { - syncOverviewAfterIndexLoad(); + function handleExamIndexLoaded(index) { + var snapshot = Array.isArray(index) ? index : []; + syncOverviewAfterIndexLoad(snapshot); var activeView = getActiveViewName(); if (activeView === 'browse') { ensureBrowseGroup().then(function afterBrowseReady() { if (typeof global.loadExamList === 'function') { - try { global.loadExamList(); } catch (_) { } + try { global.loadExamList(snapshot); } catch (_) { } } var loading = document.querySelector('#browse-view .loading'); if (loading) { @@ -2084,8 +2078,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 if (activeView === 'practice') { Promise.all([ensureBrowseGroup(), ensurePracticeSuiteGroup()]).then(function onPracticeReady() { - if (typeof global.updatePracticeView === 'function') { - try { global.updatePracticeView(); } catch (_) { } + if (typeof global.startPracticeRecordsSyncInBackground === 'function') { + global.startPracticeRecordsSyncInBackground('exam-index-loaded', { forceRender: true }); } }).catch(function handlePracticeLoadError(error) { console.error('[MainEntry] practice 视图模块加载失败:', error); @@ -2093,8 +2087,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 } } - global.addEventListener('examIndexLoaded', function onExamIndexLoaded() { - handleExamIndexLoaded(); + global.addEventListener('examIndexLoaded', function onExamIndexLoaded(event) { + handleExamIndexLoaded(event && event.detail ? event.detail.index : []); }); global.addEventListener('appCoreReady', function onAppCoreReady() { @@ -2125,7 +2119,6 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 } function init() { - setStorageNamespace(); initializeNavigationShell(); setupReadingCandidateCodeSettings(); setupPracticeTimerSettings(); @@ -2191,7 +2184,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 var settingsPrefetchPromise = null; var indexInteractionsInitialized = false; var licenseModalInitialized = false; - var LICENSE_STORAGE_KEY = 'hasSeenGplLicense'; + var licenseModalInitializationPromise = null; + var licenseModalRenderToken = 0; function ensureBrowse() { if (browsePrefetched) { @@ -2226,22 +2220,14 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样 } function ensureSettings() { - if (settingsPrefetched) { - return (settingsPrefetchPromise || Promise.resolve()).then(function () { - if (global.ExternalBackupService && typeof global.ExternalBackupService.refreshPanel === 'function') { - try { global.ExternalBackupService.refreshPanel(); } catch (_) { /* ignore */ } - } - }); + if (settingsPrefetched) { + return settingsPrefetchPromise || Promise.resolve(); } settingsPrefetched = true; var loader = global.AppEntry && typeof global.AppEntry.ensureSettingsToolsGroup === 'function' ? global.AppEntry.ensureSettingsToolsGroup : function fallback() { return Promise.resolve(); }; - settingsPrefetchPromise = loader().then(function () { - if (global.ExternalBackupService && typeof global.ExternalBackupService.refreshPanel === 'function') { - try { global.ExternalBackupService.refreshPanel(); } catch (_) { /* ignore */ } - } - }).catch(function swallow(error) { + settingsPrefetchPromise = loader().catch(function swallow(error) { settingsPrefetched = false; settingsPrefetchPromise = null; console.warn('[IndexInteractions] 预加载 settings 失败:', error); @@ -2249,8 +2235,8 @@ function ensureSettings() { return settingsPrefetchPromise; } - function startListeningSprint() { - var list = typeof global.getExamIndexState === 'function' ? global.getExamIndexState() : []; + async function startListeningSprint() { + var list = await global.resolveActiveLibraryIndex(); var listeningExams = Array.isArray(list) ? list.filter(function (exam) { return exam && exam.type === 'listening'; }) : []; if (!listeningExams.length) { if (typeof global.showMessage === 'function') { @@ -2269,8 +2255,8 @@ function ensureSettings() { } } - function startInstantLaunch() { - var list = typeof global.getExamIndexState === 'function' ? global.getExamIndexState() : []; + async function startInstantLaunch() { + var list = await global.resolveActiveLibraryIndex(); if (!Array.isArray(list) || !list.length) { if (typeof global.showMessage === 'function') { global.showMessage('题库尚未加载', 'error'); @@ -2435,6 +2421,13 @@ function ensureSettings() { ? global.OnboardingTour.start(true) : undefined; }], + ['external-backup-entry-btn', function () { + return ensureSettings().then(function () { + return global.ExternalBackupService && typeof global.ExternalBackupService.openModal === 'function' + ? global.ExternalBackupService.openModal() + : undefined; + }); + }], ['create-backup-btn', function () { return ensureSettings().then(function () { return typeof global.createManualBackup === 'function' && global.createManualBackup(); @@ -2854,12 +2847,20 @@ function ensureSettings() { return global.document ? global.document.getElementById('license-modal') : null; } - function hasAcceptedLicense() { - try { - return global.localStorage && global.localStorage.getItem(LICENSE_STORAGE_KEY) === 'true'; - } catch (_) { - return true; + function getConsentPreferences() { + var preferences = global.AppData && global.AppData.preferences; + if (!preferences || typeof preferences.getConsent !== 'function' || typeof preferences.setConsent !== 'function') { + throw new Error('AppData preferences consent API is unavailable'); } + return preferences; + } + + function hasAcceptedLicense() { + return Promise.resolve().then(function () { + return getConsentPreferences().getConsent(); + }).then(function (consent) { + return !!(consent && consent.hasSeenGplLicense === true); + }); } function showLicenseModal() { @@ -2867,14 +2868,19 @@ function ensureSettings() { if (!modal) { return; } + var renderToken = ++licenseModalRenderToken; global.requestAnimationFrame(function () { global.requestAnimationFrame(function () { + if (renderToken !== licenseModalRenderToken) { + return; + } modal.classList.add('show'); }); }); } function hideLicenseModal() { + licenseModalRenderToken += 1; var modal = getLicenseModal(); if (modal) { modal.classList.remove('show'); @@ -2882,24 +2888,37 @@ function ensureSettings() { } function acceptGplLicense() { - try { - if (global.localStorage) { - global.localStorage.setItem(LICENSE_STORAGE_KEY, 'true'); - } - } catch (error) { - console.warn('LocalStorage error:', error); - } - hideLicenseModal(); + var preferences; + return Promise.resolve().then(function () { + preferences = getConsentPreferences(); + return preferences.getConsent(); + }).then(function (consent) { + return preferences.setConsent(Object.assign({}, consent || {}, { + hasSeenGplLicense: true + })); + }).then(function () { + hideLicenseModal(); + return true; + }).catch(function (error) { + console.error('[LicenseModal] Failed to save GPL license consent:', error); + return false; + }); } function initLicenseModal() { if (licenseModalInitialized) { - return; + return licenseModalInitializationPromise || Promise.resolve(); } licenseModalInitialized = true; - if (!hasAcceptedLicense()) { + licenseModalInitializationPromise = hasAcceptedLicense().then(function (accepted) { + if (!accepted) { + showLicenseModal(); + } + }).catch(function (error) { + console.error('[LicenseModal] Failed to load GPL license consent:', error); showLicenseModal(); - } + }); + return licenseModalInitializationPromise; } global.LicenseModal = Object.assign({}, global.LicenseModal || {}, { From 7fcac67b99e9d58955ac2c9bc7026ae838c422de Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:35:46 +0800 Subject: [PATCH 15/18] docs: record the AppData v2 migration audit --- README.md | 4 +- developer/docs/tmp-feature-migration-audit.md | 205 ++++++++++++++++ findings.md | 224 +++++++++++++++++ progress.md | 141 +++++++++++ task_plan.md | 232 ++++++++++++++++++ 5 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 developer/docs/tmp-feature-migration-audit.md create mode 100644 findings.md create mode 100644 progress.md create mode 100644 task_plan.md diff --git a/README.md b/README.md index 53f65a2b..18c5d111 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ assets/generated/listening-exams/listening-index.compat.js 系统管理能力: -- 清除缓存:清理部分运行缓存并刷新状态。 +- 清除全部本地数据:删除浏览器中的练习、题库、词汇、设置、应用内备份和本地文件夹绑定,刷新后回到首次启动并重新显示 GPL 协议;外部文件夹中的 JSON 备份不会删除。 - 加载题库:导入阅读或听力题库目录。 - 主题切换:切换当前界面的背景与视觉主题。 - 题库配置切换:查看、切换或管理题库配置。 @@ -349,6 +349,7 @@ assets/generated/listening-exams/listening-index.compat.js ```bash python developer/tests/ci/run_static_suite.py +python developer/tests/e2e/full_reset_flow.py python developer/tests/e2e/suite_practice_flow.py ``` @@ -534,4 +535,3 @@ ReadingPractice/ 代码许可证见 [LICENSE](LICENSE)。使用、修改和再分发代码时,应遵守许可证条款。 题源、文章、音频、PDF、图片和解析材料可能来自第三方或原始考试资料,版权归原权利人所有。本项目不授予这些内容的商业使用权或公开传播权。使用者应自行承担因复制、部署、传播或商业化使用相关内容产生的法律和平台风险。 - diff --git a/developer/docs/tmp-feature-migration-audit.md b/developer/docs/tmp-feature-migration-audit.md new file mode 100644 index 00000000..c24ca6ac --- /dev/null +++ b/developer/docs/tmp-feature-migration-audit.md @@ -0,0 +1,205 @@ +# `tmp` 功能迁移审计 + +> 审计分支:`codex/audit-tmp-migration` +> 当前主线基准:`b39cec8` +> `tmp` 历史基线:`eb4af2f`(2026-06-19,数据层重构前) +> 审计日期:2026-07-16 + +## 结论 + +`tmp` 是旧发布目录,不是可直接合并的源码分支。它包含 502 个文件,其中 471 个与 `eb4af2f` 完全一致;`index.html`、`heroui-bridge.css`、`onboarding.css` 和大多数 bundle 都只是旧基线,`css/main.css` 只有换行差异。 + +禁止把 `tmp` 整体覆盖到当前工作树,也禁止直接编辑或复制其中的 generated bundles。真正需要迁移的功能应还原到当前 `js/...` 源文件,并重新运行 `scripts/build-bundles.mjs`。 + +## 真正的功能增量 + +### 1. 结构化阅读笔记 + +旧分支在 `tmp/js/bundles/reading-page.bundle.js` 中实现了: + +- 从原文高亮创建结构化笔记; +- `note = { id, title, body, quote, outlineId, order, createdAt, updatedAt }`; +- Notes drawer、outline 新建/改名/折叠/删除; +- 笔记拖拽归组与排序; +- 可拖动的笔记编辑器; +- 点击笔记回跳原文锚点; +- highlight 快照携带 `noteId`。 + +目标源码: + +- `js/runtime/unifiedReadingPage.js` +- `js/runtime/readingHighlightShared.js` + +### 2. 草稿与提交后注释持久化 + +旧分支保存以下阅读上下文: + +- `answers` +- `highlights` +- `noteText` +- `notes` +- `noteOutlines` +- `markedQuestions` +- `scrollY` + +并在 `pagehide` / `visibilitychange` 时强制 flush。Review 模式会尝试按原记录 id 更新笔记和标注。 + +该行为值得迁移,但旧实现把草稿和 autosaved record 直接写入 `practice_records`,不符合当前架构。阅读练习运行在 iframe 内,不能假设其中存在主窗口的 `PracticeRecordAPI`;迁移后应通过带 window/session token 校验的父子窗口消息分流: + +- 未完成草稿:发送 draft/annotation sync 消息,由父窗口写入独立 draft/meta 存储; +- 已完成记录:沿用 `PRACTICE_COMPLETE`,由父窗口调用 `PracticeRecordAPI.saveCompletion`; +- Review 纯注释更新:新增受校验的 annotation sync 消息,由父窗口 `getById` 后合并,并以原 id 调用 `saveRecord({ updateStats: false })`。 + +### 3. 结果题号回跳原文证据 + +旧分支把结果表题号变为跳转按钮,并根据 explanation locator snippet 或段落标签建立原文定位;无法精确匹配时使用 overlap fallback。 + +该功能可独立于数据层迁移,但锚点必须在文本或 DOM 改动时无损降级,不能错误绑定到其他段落。 + +### 4. 阅读显示控制 + +旧分支在 header 中动态加入: + +- 定位、笔记、高亮显隐; +- 题号导航折叠/展开; +- UI 偏好持久化。 + +这些属于 settings/UI preference,不应写入练习记录。可继续走当前设置仓库或明确的 UI preference key。 + +### 5. 旧分支的补偿逻辑 + +旧分支还实现了: + +- 隐藏 autosaved practice record; +- 按标题、秒级时间、分数等字段对历史记录去重; +- localStorage 与 IndexedDB 的 `practice_records` 合并迁移。 + +这些是旧写入模型造成的补偿,不应原样迁移。当前数据层已经集中处理 canonical record 和 id 去重;应修复生产端,不应依赖展示层掩盖重复记录。 + +## 数据接口重定向 + +当前硬规则:`practice_records` 和 `user_stats` 只能通过 `PracticeRecordAPI` 写入。`dataRepositories.practice` 已不再公开,`PracticeCore.store` 也是只读 public store,raw storage 与 `simpleStorageWrapper` 对受保护 key 的写入会抛错。 + +| 旧接口或行为 | 当前目标接口 | 迁移要求 | +| --- | --- | --- | +| `dataRepositories.practice.list()` | `PracticeRecordAPI.list()` / `listSummary()` | UI 列表优先 summary | +| `practice.getById(id)` | `PracticeRecordAPI.getById(id)` | 直接替换 | +| `practice.upsert(record)` / `PracticeCore.store.savePracticeRecord` | `PracticeRecordAPI.saveRecord(record, options)` | 必须含 canonical `examId` | +| completion payload 自拼记录再保存 | `PracticeRecordAPI.saveCompletion(...)` | 优先让当前 ingestor 建 canonical record | +| overwrite / raw `storage.set('practice_records')` | `PracticeRecordAPI.replace(...)` | 导入时控制 `updateStats` | +| remove / clear | `deleteById`、`deleteMany`、`clear` | suite 仅在明确场景按 sessionId 删除 | +| raw `user_stats` 读写 | `readStats`、`writeStats`、`mergeStats`、`resetStats` | 不迁旧浅合并算法 | +| 旧手写备份接口 | `BackupAPI` | 使用当前 normalize/restore 语义 | +| raw 题库配置和 path map 写入 | `LibraryManager` + `ResourceCore` | 保证配置、内存索引、事件和路径同步 | +| raw 词表写入 | `VocabStore` / `VocabDataIO` | 不绕过 active list 与缓存 | + +### `tmp` 中必须删除的写入路径 + +`tmp/js/bundles/reading-page.bundle.js` 的 `saveLocalReadingRecord` 会先执行 fallback,同时写: + +- `exam_system_practice_records` localStorage; +- `ExamSystemDB` IndexedDB; +- 随后再尝试旧 `PracticeStore` / `PracticeCore`。 + +这会绕过当前单写入口并造成 split-brain、重复记录或覆盖。迁移时必须删除整个 raw IndexedDB/localStorage fallback,不得包装成兼容层。 + +旧页面提交时还会向父窗口发送 `PRACTICE_COMPLETE`,父窗口再执行一次 canonical save,因此原实现存在双写路径。完成记录必须只保留父窗口这一条写入链路。 + +## Canonical schema 需要先扩展 + +当前 `PracticeCore.contracts.standardizeRecord` 尚未完整声明结构化 `notes` / `noteOutlines`。UI 迁移前,应在 canonical contract 中明确以下字段,并同步 completion、suite entry、replay 和 recorder 路径: + +- `highlights: array` +- `markedQuestions: array` +- `noteText: string` +- `notes: array` +- `noteOutlines: array` +- `scrollY: number` + +建议把完整 replay 数据保存在 `realData`,顶层只保留当前消费者确实需要的轻量镜像。`listSummary()` 必须继续剔除完整笔记正文、原文 quote 和重型 replay 数据。 + +Review 更新必须保留原 `id`、`examId`、score、timing 和 completion 状态,并使用 `updateStats: false`,避免编辑笔记时重复累计统计。 + +## 题库内容审计 + +旧分支 manifest 新增 5 篇题目,其中 4 篇当前已经以更新后的 ID/频率存在: + +| `tmp` ID | 当前 ID | 结论 | +| --- | --- | --- | +| `p1-high-242` | `p1-high-240` | 已存在,不重复迁移 | +| `p2-high-243` | `p2-low-240` | 已存在,不重复迁移 | +| `p2-high-244` | `p2-low-242` | 已存在,不重复迁移 | +| `p3-high-241` | `p3-medium-241` | 已存在,不重复迁移 | +| `p3-high-240` Songs of Ourselves | 无 | 旧分支独有内容,不属于功能迁移范围 | + +题库以当前远端主线为唯一权威来源。`Songs of Ourselves` 及其他旧分支独有内容不迁移;也不能批量覆盖 `tmp` explanations,因为审计发现旧解释中存在答案字段与解析结论冲突的样本。 + +以下内容明确拒绝迁移: + +- `tmp` 中未进入 manifest 的孤儿副本; +- 旧分支把本地图改为 postimg CDN 的变更; +- 已在当前主线存在但 ID/难度已修正的 4 篇重复题; +- 缺少对应文件的旧 manifest 项 `p2-high-26`。 + +## 不应迁移的旧版本回退 + +- 不迁 `session.bundle` 中删除 `suiteTimerMode` / `suiteTimerLimitSeconds` 的改动; +- 不迁阅读页删除倒计时警告、自动交卷和当前 timer preference contract 的改动; +- 不整体替换 reading template、`index.html` 或 CSS; +- 不恢复 `dataRepositories.practice`、`PracticeCore` public write methods 或 raw protected-key adapter; +- 不把 generated bundle 当 source of truth。 + +## 推荐实施顺序 + +1. 扩充 canonical annotation/draft schema,并给 `PracticeRecordAPI` 增加对应契约测试。 +2. 在父窗口建立局部 `readingDraftGateway`:草稿走独立受控 draft key,completed/review 走 `PracticeRecordAPI`;iframe 只发送受 token 校验的消息。 +3. 迁移结构化 notes 与 `noteId` highlight snapshot/restore。 +4. 迁移结果定位与显示控制,保持它们与记录存储解耦。 +5. 重建 bundles,执行单元、静态和浏览器回归测试。 + +## 测试门禁 + +至少覆盖以下场景: + +- canonical normalize/save/reload 后 `notes`、`noteOutlines`、`noteId` 不丢失; +- in-progress draft 不出现在 practice history,也不更新统计; +- completed record 只保存一次,reload 后可 replay; +- Review 编辑笔记更新同一 id,score、duration、date 和 stats 不变; +- suite 三段草稿隔离且 timer contract 不回退; +- note quote 找不到或 DOM 变化时不误绑、不丢正文; +- Notes drawer outline 增删改、拖拽排序和键盘操作; +- locator 精确匹配、fallback、无匹配三种路径; +- 伪造或过期 window/session token 的 annotation sync 被拒绝; +- 窄屏 Notes drawer/editor 不被 `z-index: 2000` 的底部 practice nav 遮挡; +- 旧 `#notes-panel` / `#note-btn` 与新 Notes drawer 只有一个权威状态和入口; +- bundle 重建后与源码一致; +- 当前远端题库的 exam/explanation/manifest 内容保持不变。 + +建议复用并扩展: + +- `developer/tests/js/practiceCore.test.js` +- `developer/tests/js/practiceRecorder.test.js` +- `developer/tests/js/practiceRecordPersistence.test.js` +- `developer/tests/js/storageManagerRecords.test.js` +- `developer/tests/js/unifiedReadingCoreRegression.test.js` +- `developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js` +- `developer/tests/e2e/reading_single_flow.node.js` +- `developer/tests/e2e/simulation_roundtrip_restore_regression.py` +- `developer/tests/ci/check_reading_data_integrity.py` + +建议验证命令: + +```powershell +node scripts/build-bundles.mjs +node developer/tests/js/practiceCore.test.js +node developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js +node developer/tests/js/suiteModeRegression.test.js +node developer/tests/js/practiceCompletionFlow.test.js +node developer/tests/js/unifiedReadingCoreRegression.test.js +py developer/tests/ci/run_static_suite.py +node developer/tests/e2e/reading_single_flow.node.js +``` + +审计时的只读基线:`practiceCore.test.js` 13/13、inline suite、suite mode 和 completion flow 均通过;`unifiedReadingCoreRegression.test.js` 在当前主线已有一项失败(`submit should notify host before explanation rendering completes`,实际通知数 `0`、预期 `1`)。迁移前需先把该既有红灯登记或修复,否则无法用它判断迁移回归。 + +本审计所列的结构化笔记、受控草稿、回顾注释同步与数据契约迁移已在本分支实现;旧分支题库内容未迁移。 diff --git a/findings.md b/findings.md new file mode 100644 index 00000000..20b88cf0 --- /dev/null +++ b/findings.md @@ -0,0 +1,224 @@ +# Findings + +## Baseline + +- Branch: `codex/audit-tmp-migration` +- HEAD: `2a1801cd6f8ddc721a6971e9e07c39623083cc2d` +- Divergence from `origin/opensource`: 7 upstream-only / 25 branch-only commits. +- Existing dirty files are user-owned accuracy UI and generated bundle changes; preserve them. + +## Verified defects from the audit + +- Full restore writes only present document envelopes and listed entity stores. +- Partial practice replace can leave detail/annotation orphans. +- Operation journal survives restore and business IDs are reused as operation IDs. +- v1 nested annotations and `scoreInfo.correct/total` are lost from light/full projection. +- Concurrent vocab writes conflict and one update is lost. +- `file://` fallback uses `"file://"` as target origin; Chromium silently drops the message. +- Listening marks completion before INIT/ACK. +- `examSessionMixin.createFallbackRecorder` overwrites the safer app-level fallback. +- DataKernel listeners are realm-local; file-page BroadcastChannel was proven available. +- Row checksum errors currently latch the entire backend. + +## Prior live probes + +- Full restore left a post-backup vocab word in place. +- Nested v1 import produced `correctAnswers=0`, `totalQuestions=0`, and null annotations. +- Two concurrent vocab upserts produced one fulfillment, one `CONFLICT`, and one saved word. +- Reusing a record ID as operation ID rejected the changed second save. +- A commit in a second file-page realm was readable by the first realm but absent from its committed listener. + +## Implementation decisions + +- Full-user-data mirror semantics will be prepared in `createImportPlan`; DataKernel stays a validated atomic installer and receives explicit cleared envelopes/all entity layers. +- `resetJournal` is applied inside the same install transaction after checking replay against the old journal; the new journal contains only the restore receipt. +- Partial practice imports may update a subset of stores only when the resulting three recordId sets remain identical; incomplete replace is rejected. +- Cross-realm commit broadcast must not depend on local listeners being registered, otherwise a writer-only child realm would never notify the parent. +- User-owned accuracy changes are source-backed and will be preserved by rebuilding bundles from the modified source files. + +## Implemented worktree checkpoint + +- `appData.js` now normalizes accuracy values above 1 to ratios, derives `correctAnswers/totalQuestions` from both `scoreInfo.correctAnswers/totalQuestions` and legacy `scoreInfo.correct/total`, and emits percentage separately. +- `splitPracticeRecord` now fills top-level annotation fields from `realData`, then `rawData`, without persisting either legacy mirror. +- Practice light projection now emits sanitized `suiteEntrySummaries`; Browse consumption and regression coverage remain pending. +- `dataKernel.js` has a partial implementation for `CORRUPT_RECORD`, cross-realm commit broadcast, and restore journal reset; exact transaction/error boundaries still need focused source review. + +## Remaining data-layer gaps after source review + +- `createImportPlan` still has the old contract: full replace only iterates present envelopes, and practice replace only clears/rebuilds stores present in the payload. It must synthesize cleared envelopes for every importable/exportable user-data catalog entry and require all three practice stores for replace. +- Practice merge currently snapshots only source stores and can still produce unequal recordId sets. The resulting three-store sets need validation before `installSnapshot`. +- `backups.restore` does not pass `resetJournal: true`; DataKernel supports it but the public restore path currently preserves the old journal. +- `vocab.saveCollection`, `saveCollections`, `upsertCollectionWord`, `mergeListWords`, and related collection read-modify-write paths are still unqueued bare CAS operations. +- `operationId()` already generates a fresh ID by default; the remaining idempotency bug is at callers that explicitly pass `record.id/sessionId`. Same-request retries must preserve an explicit request/submission operation ID. +- Full-mirror document scope is exactly catalog entries with `export:true` and an import policy other than `ignore`; this includes `backups.settings` but excludes `backups.entries`, backup history, `recovery.windowSession`, and the journal. +- `DataKernel.listEntities` must skip corrupt summary rows so stats/list rendering continue; direct `readEntity` remains the diagnostic path that returns `CORRUPT_RECORD`. +- Vocab RMW coverage is broader than `upsertCollectionWord`: collection/config/word merge, patch, and progress methods all read revisions before CAS. A single realm-local queue plus bounded re-read/retry is the simplest consistent contract. + +## Messaging review checkpoint + +- Fallback `main.js` still computes `targetOrigin` by accepting any truthy origin before testing `protocol === 'file:'`; therefore a Chromium value of `"file://"` still reaches `postMessage`. The file check must take precedence and the declared parent origin must be normalized to `"null"`. +- Listening still sets `state.completed = true` before INIT/ACK and only retries while `!state.completed`; it has no persisted submission receipt handler. +- Reading highlight UI still treats successful `postMessage` invocation as persistence success. It needs a generated `requestId`, pending request map, ACK/FAILED listener, and direct-AppData fallback only when no host route is available. +- `app.js` defines the richer fallback recorder, while `examSessionMixin.js` still defines another `createFallbackRecorder`; the later mixin assignment can overwrite the richer implementation. +- The main exam mixin already has the correct opaque-origin endpoint (`expectedOrigin:"null"`, wildcard send) and a persisted `PRACTICE_SUBMIT_ACK/FAILED` receipt cache. Listening can join that contract without inventing a second host protocol. +- Listening bootstrap creates a provisional sessionId before INIT; therefore a pre-INIT completion must cache extracted details/submissionId, then build the final payload only after host INIT replaces the provisional sessionId. +- Completion retry timers must resend the cached payload directly. Calling `onComplete()` and rescheduling all timers from inside each timer would create an endless timer-reset loop. +- Business-id reuse existed in `PracticeRecorder.savePracticeRecord`, suite finalize, and the host completion fallback. Recorder now owns one new op per call and reuses it only for its internal retries; submission-correlated host/suite saves derive the op from submissionId. +- `handlePracticeComplete` called the general recorder-session rebind and then called the listening wrapper that delegates to the same general rebind, producing duplicate `handleSessionStarted`; the second call is redundant. +- The remaining suite reset failure was fixture accounting, not a second reset call: the first reading completion correctly invokes the general recorder rebind, leaving one entry in `recorderStarts`; reset invokes `_syncRecorderSessionStarted` once. Reset the probe before the reset request so the assertion measures only reset behavior. +- The Listening bridge already has the intended pending state machine in source: `onComplete` creates one submission, `sendPendingCompletion` refuses to emit before trusted INIT, and retry timers resend the cached payload. The existing parser test is not visibly read by ordinary `Get-Content`, so inspect its encoding before extending it. +- `listeningRecordBridgeParser.test.js` is normal UTF-8 and only covers safe literal parsing; protocol coverage belongs in a separate VM harness. The bridge deliberately exposes `__listeningBridgeComplete` and `__listeningBridgeGetState`, which makes pre-INIT/retry/ACK assertions possible without production-only test hooks. +- The static runner has an explicit `security_regression_tests` list, so the new Listening protocol VM regression must be registered there rather than merely added to the filesystem. +- The vocab dictionary's public API exposes ACK settlement but not the save initiator. A VM-only source injection can expose closure hooks without changing production code, allowing exact assertions that `postMessage` delivery leaves the button pending, FAILED shows `保存失败` when direct AppData is unavailable, ACK shows `已加入`, and direct AppData commit is also accepted. +- Browse completion is not owned by `browseController.js`; that module only controls modes and filters. Existing Browse record tests exercise `BrowsePreferencesUtils`, so the child completion consumer must be located in the legacy view/presentation layer before choosing the regression harness. +- The actual Browse completion index is `rebuildBrowseCompletionIndex` in `legacyViewBundle.js`; both its indexed path and path/file fallback enumerate only `record.suiteEntries`. The existing `legacyViewReadStatus.test.js` is the exact regression surface and currently labels a full `suiteEntries` object as “lightweight”. +- The cross-realm chain is correctly connected: DataKernel remote BroadcastChannel events are dispatched with `remote:true`; `AppData.backups.onDataCommitted` directly subscribes to that kernel listener; ExternalBackupService subscribes once and calls `markDirty` without filtering remote events. The backup regression should emit an explicit remote event so this contract cannot regress silently. +- `practiceRecordPersistence.test.js` already drives the app-level fallback handshake/submit path, but its harness hardcodes an HTTP origin and discards `postMessage` targetOrigin. Extending this existing test to run the fallback completion under `location.protocol='file:'`, `origin='file://'`, event origin `null`, and asserting every reply target is `*` is the smallest realistic origin regression. +- The production protocol scan found completion senders in the inline suite placeholder, Practice Enhancer, unified reading, Listening bridge, and shipped templates, plus an E2E inline fixture. The host now rejects missing correlation metadata, so each sender path must be checked for enrichment rather than assuming literal payloads contain the fields. + +## Current Production Reports + +- Built-in Reading exams must come strictly from `assets/generated/reading-exams/manifest.js`; imported-library state or AppData readiness must not replace an available built-in index with an empty list. +- `assets/generated/listening-exams/manifest.js` may be absent in distributed packages. Its loader is optional and must not gate Reading browse startup or practice submission. +- The reported save failure reaches `AppData.practice.completeAttempt` through `ExamSystemApp.saveRealPracticeData`; `canonicalizeRecord` rejects `correctAnswers` because the upstream completion normalization produced a negative or non-finite number. The upstream computation must be fixed rather than weakening the non-negative persistence invariant. + +## Confirmed Root Causes + +- `js/data/v2/dataKernel.js:143-160` reads legacy IndexedDB rows as the value itself. The v1 store actually persisted `{ key, value, timestamp }`, so `practice_records`, library configuration, and active-key values are currently parsed one level too high. The practice migration therefore sees no array and imports zero records. +- v1 used exact `exam_index` as the built-in/default-library sentinel. `migrateLegacyData` copies that value to `library.activeConfigurationId`, while `importedLibraryId` rejects every `exam_index`/`exam_index_*` ID. `LibraryManager.loadActiveLibrary` then treats the invalid sentinel as a non-default library, receives an empty index, dispatches `examIndexLoaded` with `[]`, and never reads the generated Reading manifest. +- Existing poisoned v2 envelopes survive a source fix because document migration skips any existing envelope and practice migration returns as soon as one summary exists. The repair must be idempotent and must merge missing legacy records instead of using collection non-emptiness as completion. +- Reading, Listening, and the generic practice enhancer use object-valued `correctAnswers` for the answer-key map and place the numeric score in `scoreInfo.correct`. Suite aggregates use numeric top-level `correctAnswers`. `canonicalizeRecord` currently validates the overloaded object field before adapting it, so a valid completion fails persistence. The canonical non-negative invariant is correct; the compatibility boundary must select the first valid scalar score candidate and preserve the map separately. +- `LibraryManager.loadActiveLibrary` already treats the Listening manifest as optional for a default library, but an invalid/non-default active ID returns an empty custom index before reaching that code. Empty or invalid active custom state must reset to the default and continue through the manifest path. + +## Chosen Hotfix Contract + +- Unwrap legacy IDB rows strictly through `.value`; no historical production writer supports raw business values in that object store. +- Translate the v1 exact default sentinel to v2 `null`; never persist the generated Reading `exam_index` cache as user library data. +- Remap valid v1 custom `exam_index_*` libraries to accepted deterministic IDs, but only when their index is a non-empty array. +- Repair poisoned active-library state on startup and make browse startup fall back to the generated Reading manifest when a selected custom library is missing or empty. +- Merge missing legacy records by stable ID, skip already-migrated IDs, and use a versioned repair operation ID. +- Normalize overloaded completion score fields before canonical validation; preserve `correctAnswerMap`, keep legal zeroes, and never accept a negative/non-finite candidate when a later valid candidate exists. + +## Supplied Backup: Confirmed Semantics + +- `ielts-atlas-backup-2026-07-28T15-14-09-096Z.json` is checksum-valid (`fnv1a-88bc05b5`) but semantically poisoned: all three practice entity stores are empty, `library.activeConfigurationId` is `"[object Object]"`, `library.importedIndexes` is missing, and settings/vocab/achievements contain old `{key,value,timestamp}` rows rather than business values. +- The exporter faithfully captured an already-corrupted v2 database. The product defect is that it labeled a sparse physical snapshot as `scope:"full"`, generated a valid checksum, and provided no semantic validation or completeness manifest. +- Old opensource main UI exports practice records and stats and defaults to merge; it does not import library configuration/active state, so its normal merge/replace path cannot clear the built-in library. The dangerous old `StorageManager.importData` full clear path existed but was not the normal DataBackupManager UI. +- The v2 migration row-wrapper bug is a new regression. Old `StorageManager.getFromIndexedDB` correctly read `request.result.value`; v2 migration read the entire row. +- The supplied file cannot reconstruct missing practice records or lost library configurations by itself. It can safely recover inner settings/vocab/achievement values. Practice/library recovery additionally needs the original `ExamSystemDB` or another older backup. +- The built-in Reading manifest is code, not user data. Only user custom-library configurations/indexes/active selection belong to snapshot state; `null` active always means load the generated manifest. +- A checksum proves byte-level integrity, not business correctness. Import preview must distinguish `trusted-full`, `degraded-partial`, and `invalid` inputs. +- New exports now materialize every exportable catalog key as an explicit `present` or `cleared` envelope. Missing keys in older sparse snapshots are preserved rather than inferred as deletion requests. +- v2 import canonicalization repairs only exact legacy row aliases and rejects cross-domain wrappers. Full snapshots require a coherent library bundle; valid partial library updates remain importable. +- Destructive import preview reports existing/incoming/final/removed practice counts. Commit requires explicit `confirmDestructive:true` after user confirmation. +- Startup uses one versioned `v1ToV2` state. It migrates only an empty v2 database or repairs an exact known poison fingerprint; marker absence alone never replays a frozen v1 database. +- The supplied JSON itself still cannot yield missing practice records: its three entity arrays are genuinely empty. Recovery succeeds only if the user's old IndexedDB or another older backup still contains those records. + +## Raw-Data-First Re-audit (Final) + +- Historical production has one IndexedDB row contract: `{key,value,timestamp}`. Its `value` is the serialized storage envelope `{data,timestamp,version[,compressed]}`. The old reader returned `request.result.value`; the bad v2 migration uniquely passed the whole row to the legacy parser. +- The whole corruption chain comes from that one wrong boundary. Object documents retained the wrapper, array documents normalized to `[]`, nullable strings became `"[object Object]"`, and practice extraction found no record array. Checksums later certified those already-wrong bytes. +- Raw unprefixed compatibility exists only in Web Storage for `practice_records`, `vocab_user_config`, and `user_achievements`. The reader now accepts exactly those evidenced variants and no speculative raw-IDB shape. +- Initial migration runs only when v2 has no user envelopes and no practice summaries. Exact wrapper/library poison may trigger a narrow repair. A healthy existing v2 database is marked `existing-v2` without reading or replaying frozen v1 data. +- Wrapper repair requires the expected legacy alias and an object payload. It preserves fields added to the outer v2 document after the bad migration. +- Library poison repair restores only exact wrapped legacy index IDs. A poisoned active ID consults the old active ID only when v2 has no usable current index; otherwise it becomes the built-in/default selection and existing v2 custom libraries remain untouched. +- Practice recovery from old storage is limited to initial migration or an exact poisoned state with an empty summary store. This is the only unavoidable ambiguity: after a bad migration, an intentionally cleared empty practice store is indistinguishable from the original collapse while the poison fingerprint remains. +- The built-in Reading index is never user data. `null`/invalid/empty custom selection displays `assets/generated/reading-exams/manifest.js`; this fallback does not mutate the persisted selection. A healthy custom library remains active even during forced reload. +- Sparse or poisoned old backups are degraded to partial imports. Missing keys do not imply deletion, and the supplied file's merge path preserves current practice/library data. Explicit destructive replace requires `confirmDestructive:true`. +- The supplied backup cannot recover practice records or custom indexes because those arrays/envelopes are already empty or absent. Recovery requires the user's surviving old `ExamSystemDB` or an older intact backup; no code fallback can reconstruct data that is absent from both. + +## Persistent v1 Reconciliation Decision + +- The user explicitly prefers recovery completeness over preventing old v1 records from reappearing after a later v2 deletion. +- Required startup behavior: if canonical v1 data is readable, merge every valid v1 practice record and user library into v2 on every startup; retain valid v2-only additions; replace known wrapper/`"[object Object]"` migration poison with decoded v1 values. +- Repeated startup must be idempotent by stable record/library IDs and checksum/revision comparisons, not by skipping legacy reads through a completion marker. +- The completion marker is diagnostic only. It must never suppress a legacy read or reconciliation, and it must not be rewritten when no business data changed. +- Practice reconciliation is record-based: a complete healthy v2 three-layer record wins on an ID collision; a missing v1 ID is added; a partially present v2 record is replaced atomically from v1 to avoid mixed summary/detail/annotation provenance. +- Library reconciliation is a deterministic union by remapped legacy ID. Healthy v2-only libraries and healthy active selections survive; missing v1 libraries are added; poisoned or dangling active selection is repaired from the v1 active key. +- A failed or incomplete legacy read produces no v2 writes and no marker update; the application continues on existing v2 and retries next startup. +- Exact current gates to remove are `migrateLegacyData`'s completed-marker return and healthy-v2 `existing-v2` return. Library, document, and practice reconciliation must no longer depend on `freshMigration`, `poisonDetected`, or an empty summary collection. +- Existing `practiceLayers(..., true)` exposes all three revisions and `practiceUpserts` already emits a single atomic three-store mutation, so a partial record can be replaced coherently without adding a new kernel repair API. +- `migrateLegacyLibraryData` already compares configuration/index checksums before writing; changing it to an unconditional deterministic union keeps repeated startup diff-only. +- The VM regression now proves that an old completion marker cannot suppress reconciliation, v1-only and v2-only records coexist, complete healthy same-ID v2 records win, partial three-layer records are atomically rebuilt from v1, and a second boot rereads v1 without incrementing any business revision. +- The real IndexedDB regression now updates `ExamSystemDB` after a completed v4 reconciliation, reloads into a new realm, verifies the newly appended v1 record is migrated beside a v2-only record, then proves a third unchanged boot has identical document/entity revisions and checksums. +- Exact wrappers are not the only historical bad output: array/object legacy documents written by the faulty migration can be recognized by their `legacy-documents-*` operation ID. Those documents should be refreshed from the live v1 alias when values differ; later normal v2 writes have a different operation ID and remain authoritative. +- `AppData.practice.delete` removes all three layers without a tombstone. Under the user-selected persistent-union policy, deleting a record that still exists in v1 must therefore be temporary: the next startup restores it. +- Catalog policies provide the general document merge contract needed for “all v1 data”: `patch` objects should include v1-only keys while healthy v2 values win conflicts; `merge-by-id` arrays should include v1-only items while healthy v2 items win the same identity. Exact bad-migration operation IDs remain a full v1 replacement. +- The existing `mergeImportValue`/`mergeCollection` helpers already implement those policies. Calling them as `(legacyValue, currentV2Value)` produces the desired union with healthy v2 winning collisions and avoids a second merge implementation. +- Persistent document reconciliation now applies those catalog policies on every startup: the test proves legacy-only settings and vocabulary are added, current v2 values win shared keys/IDs, and exact bad-migration writes are still replaced rather than merged. +# 2026-07-30 Review Fixes, v2 Insights, And Endless Mode + +## Confirmed review regressions + +- `js/data/v2/appData.js`: persistent legacy reconciliation currently re-merges `active_sessions`, `temp_practice_records`, `interrupted_records`, and `rejected_completion_payloads`, resurrecting v2-deleted recovery rows. +- `js/utils/BrowsePreferencesUtils.js`: first synchronous preference read returns/caches defaults while async AppData hydration finishes without reapplying the initial filter/scroll state. +- `developer/tests/ci/run_static_suite.py`: the exam app method-contract collector scans only `js/app/*Mixin.js`, while `createFallbackRecorder` now exists only in `js/app.js`. +- `js/data/v2/appData.js`: achievement projection supports an existing unlocked state, but `getAll()` always passes `{}` and no durable v2 progress document currently owns new unlocks. +- `developer/tests/e2e/suite_practice_flow.py`: the E2E predicate reads properties from the Promise returned by async `resolveSuitePreference()`. +- `js/boot-fallbacks.js`: pre-import backup creation happens before semantic preview and user confirmation. + +## Lightweight practice insight gap + +- `js/main.js` loads `AppData.practice.list({ projection: 'light' })`, but `PracticePriorityRenderer.calculateReadingRadarData()` reads `questionTypePerformance`, `answerDetails`, and `scoreInfo.details`, all of which live only in v2 detail records. +- The production radar therefore receives records with no classifiable wrong-answer data and reports zero errors. +- Suite child records are deleted after finalization; `suiteEntrySummaries` currently preserve score metadata but no compact question-type error counts. +- The appropriate contract is a compact derived `questionTypeErrorCounts` field on summaries and suite-entry summaries, not a fallback to loading every detail record. +- `filterByExamType()` also ignores existing `suiteEntrySummaries`; it can consume those without a new API. + +## Endless mode + +- `js/presentation/app-actions.js` initializes `endlessState` as `null`, writes `endlessState.examIndex` before constructing the object, and deterministically throws on the first start. +- The generated runtime-entry bundle contains the same defect and is what `index.html` executes. +- The unified reading page only emits `ENDLESS_USER_EXIT` when an endless marker is present, but current first/subsequent exam opens do not add that marker. +- Subsequent endless exams manually navigate/register/start a session instead of using the normal `app.openExam()` lifecycle, risking stale window/session state. +- Focused regressions must execute the lifecycle; the existing endless test only scans source strings. + +## Source-contract decisions after main-agent read + +- The root README confirms bundles are the only production runtime and must be rebuilt from source; `file://` remains a required execution mode. +- Recovery documents are cataloged as authoritative/exportable `merge-by-id` data. Their backup/import semantics should remain intact; only startup legacy reconciliation needs a one-shot policy. +- `lightFromCanonical()` is the canonical summary constructor and `lightSuiteEntry()` is the canonical compact suite-entry constructor, so derived error counts belong in those two functions and will naturally persist in `practiceSummaries`. +- `filterByExamType()` currently consults the entire exam index before record metadata. It should first honor `suiteEntrySummaries`, then the summary's own type, and only use the exam index as a legacy fallback. +- The current import fallback has a clean sequencing boundary: preview and optional confirmation end immediately before `commitImport()`, making backup creation safe to move to that point without changing payload validation. +- `openExam()` already owns reused-window cleanup, launch-library provenance, session registration, recorder start, and injection. Endless follow-up navigation should call this path instead of duplicating those responsibilities. +- Achievement tests currently assert `getAll()` performs one `practiceSummaries` list and no document reads; adding durable progress intentionally changes that contract to one `achievements.progress` read and requires updating the focused harness/catalog expectations. +- Existing Browse preference coverage is concentrated in `developer/tests/js/browsePreferencesRecords.test.js`; it already models AppData preference failures and is the right place for delayed-hydration ordering coverage. +- The static method contract has a single collector in `run_static_suite.py`; scanning `app.js` alongside mixins fixes the source-of-truth mismatch without duplicating a method. +- The recovery facade exposes discard/complete methods that write a cleared/current v2 envelope, so the transient reconciliation regression can model the real user path and reboot the shared kernel. +- `unifiedReadingLockRegression.test.js` is already registered by the static suite and can host an executable VM lifecycle check for first open and countdown-driven window reuse without adding another runner block. + +## Final implementation and residual gates + +- New practice writes persist answer-free `questionTypeErrorCounts` in summaries and suite-entry summaries. `practice.listInsights({limit:10})` supplies the same contract for historical rows by reading only the bounded missing details; annotations and all-history detail scans remain excluded. +- Browse activation now awaits preference hydration before reading the persisted filter, and initial preference UI/scroll restoration use the same readiness promise. +- Achievement unlock facts are stored in exportable/importable `achievements.progress`; deleting source practice rows no longer relocks them. +- Endless mode now constructs state atomically, marks the unified URL, opens first and later exams through `app.openExam()`, reuses the stable tab, and cleans up/report failures. +- The final full static report passed every gate changed by this work. Its remaining failures are outside this scope: the pre-existing v2 migration allowlist mismatch, noisy suite-regression JSON parsing, four NB replay content fixtures, and the 480-second Reading quick audit timeout. +- The suite E2E reached lazy loading, persisted preference setup, and window launch after its two runner API fixes, then stopped at the existing first-passage readiness timeout caused by unavailable local exercise resources. + +## Residual gate triage + +- The v2 unique-entry failure is a guard allowlist drift: `run_static_suite.py` still anchors the allowed AppData legacy-import region at the removed `findDeclaredValue` symbol. The legacy reads are confined to the intended v1 compatibility/migration boundaries; repair the semantic allowlist and keep a negative guard case. +- `suiteModeRegression.test.js` exits successfully and prints pass JSON on its final stdout line. The static runner incorrectly parses the entire noisy stdout as one JSON document even though it already has a last-line JSON helper. +- All four NB replay failures are stale tests. The runtime now requires a trusted `INIT_SESSION`, `source: exam_host`, and a matching window token; with that protocol the four resources restore answers, answered state, highlights, text, and mirror data correctly. `p2-high-201` also needs its test selector scoped to the clone group. +- Reading quick is not merely a slow-machine timeout. It spends about 218 seconds launching Node/VM once per 232 static datasets, then twelve UI cases each exhaust a 30-second wait for an obsolete or premature `#results` contract. Fix the page-ready/result contract and batch the static exporter before revisiting the 480-second outer bound. +- Suite E2E does not fail because the optional listening manifest is absent. `_buildExamPlaceholderUrl()` drops the parent's test mode, so `exam-placeholder.html` identifies itself as non-test, sets `examState=blocked`, and never enables completion. Propagate the narrow `suite_test=1` flag and rerun the full suite chain. +- Release recommendation: repair the three test-infrastructure failures promptly to restore a trustworthy green gate; keep Reading UI and suite end-to-end paths release-blocking until their real chains run successfully. + +## Residual gate repair design + +- Reading quick currently opens the unified page as a top-level `file://` document. Even after the click handler binds, submission intentionally cannot post to itself; results render only after a correlated `PRACTICE_SUBMIT_ACK`. The audit must host the page in an iframe, perform the existing `REQUEST_INIT`/`INIT_SESSION` handshake, wait for `SESSION_READY`, and ACK `PRACTICE_COMPLETE`. +- No new Reading runtime-ready sentinel is needed: `SESSION_READY` is emitted only after action and message listeners have been attached. +- The Reading exporter can add an `--all` mode that loads all 232 registered datasets into one VM/context. Python should consume that bundle once instead of launching Node once per exam. +- Suite placeholder propagation should use the already-supported narrow `suite_test=1` query flag. The template and environment detector need no behavior change. +- The NB replay fixture must retain the production trusted-message gate and instead send a valid INIT plus matching token/source. Its selectors must be scoped to the same clone-enabled group so `p2-high-201` covers the intended case. + +## Final verification (2026-07-31) + +- Placeholder reuse can render before the next `INIT_SESSION`; URL-level `suiteFlowMode` recovery now makes simulation/stationary behavior deterministic, while late contexts preserve submitted-final navigation. +- The unified static suite passes all checks. The only fixture-level correction was adding `practice.listInsights()` to the practice-persistence AppData stub. +- Bundle drift is green for all 14 outputs; eight historical symbol-collision warnings remain explicitly non-blocking. +- The legacy migration fixture should derive its fresh timestamp from `Date.now()` and include a 31-day stale row to keep TTL cleanup explicitly covered. +- Main-agent marker verification confirmed bounded semantic regions: DataKernel legacy constants end before `function clone`; AppData import recognition ends before `entityRowFromLayer`; AppData document migration ends before kernel initialization; migration fixtures are bounded by their harness/main functions rather than whole-file exemptions. +- The existing reliable-submit E2E already contains a compact file-compatible iframe host and correlation helpers. Reading audit can embed a smaller auto-ACK variant, operate on the named frame, and retain page-level screenshots/console collection. +- `suiteModeRegression.test.js` already exposes native `URL`/`URLSearchParams` in its VM sandbox, so the placeholder URL query and special-character round-trip can be covered without new harness dependencies. diff --git a/progress.md b/progress.md new file mode 100644 index 00000000..f4cb297e --- /dev/null +++ b/progress.md @@ -0,0 +1,141 @@ +# Progress + +- 2026-07-28: Read `planning-with-files` instructions and ran session catch-up. +- 2026-07-28: Captured baseline branch, HEAD, divergence, and preserved dirty-file list. +- 2026-07-28: Initialized implementation plan and findings files. +- 2026-07-28: Default-mode subagent dispatch also returned `unsupported call`; switched to parallel read-only shell probes. +- 2026-07-28: Captured existing UI source diffs and selected restore/journal/broadcast implementation contracts. +- 2026-07-28: Implemented DataKernel cross-realm commit broadcast, `CORRUPT_RECORD` isolation, restore journal reset, and restore commit notifications; syntax check passed. +- 2026-07-28: Session catch-up found an additional unsynced `AppData` projection patch in the worktree; exact projection/import coverage still requires diff review and focused tests before phase 2 can be marked complete. +- 2026-07-28: Attempted the mandated read-only subagent split; `spawn_agent` again returned `unsupported call`. Stopped repeating the unavailable call and switched to parallel shell probes. +- 2026-07-28: First parallel shell probe aborted because this repository has no root `package.json`; adjusted discovery to locate manifests/build runners instead of assuming layout. +- 2026-07-28: Reviewed the unsynced AppData diff: legacy nested annotations, score aliases/accuracy normalization, and sanitized suite light summaries are implemented in source; consumers/tests are not yet verified. +- 2026-07-28: Completed focused restore/vocab review. Full mirror/three-layer invariant and public restore journal reset are still missing; vocab collection RMW paths remain bare CAS. +- 2026-07-28: Implemented full-scope cleared envelopes, atomic three-layer import planning with recordId-set validation, restore journal reset wiring, and corrupt-summary skipping. Syntax passed; the old orphan-preserving test now fails at the intended new validation gate. +- 2026-07-28: Added a single vocab mutation queue with bounded fresh-read CAS retries across config, collections, word merge/patch, and progress paths. +- 2026-07-28: Replaced the orphan-preserving test with full-mirror, incomplete replace, recordId-set, nested v1, sanitized suite light, journal reset, concurrent vocab, BroadcastChannel, and corrupt-row isolation regressions. Focused AppData/DataKernel tests pass. +- 2026-07-28: Completed the protocol source read. Main mixin ACK/origin infrastructure is reusable; fallback origin, duplicate recorder, Listening pending submission, and vocab request receipts remain to implement. +- 2026-07-28: Implemented fallback opaque-origin normalization and required submission IDs; removed the duplicate mixin fallback recorder. +- 2026-07-28: Listening now caches pre-INIT completion details, generates one submissionId, resends the same persisted request, and marks completed only on trusted ACK. +- 2026-07-28: Reading highlight vocab now generates requestId, waits for trusted host ACK/FAILED, falls back to direct AppData commit, and never labels a bare postMessage as success. +- 2026-07-28: Separated practice record/session business IDs from mutation operation IDs in PracticeRecorder, suite finalize, and host persistence; added per-call/new-op and internal-retry/same-op tests. +- 2026-07-28: Resumed from persisted plan; subagent dispatch remained unavailable with `unsupported call`. +- 2026-07-28: Diagnosed the suite reset regression as stale fixture accounting and reset the recorder-start probe before the reset request. +- 2026-07-28: `suiteModeRegression.test.js` passes after isolating reset-time recorder synchronization in the fixture. +- 2026-07-28: Located existing focused surfaces for the remaining gates: listening parser, unified reading protocol, external backup v2, Browse controller/preferences, and file/listening E2E runners. +- 2026-07-28: Confirmed the Listening source state machine and identified the parser test file as an encoding/readability edge case requiring byte-level inspection before editing. +- 2026-07-28: Confirmed the parser test is ordinary UTF-8; selected a separate VM-based Listening protocol regression using the bridge's existing public test hooks. +- 2026-07-28: Designed the Listening protocol harness to assert file-origin wildcard sends, no pre-INIT completion emission, same-submission retry, forged ACK rejection, and trusted ACK finalization. +- 2026-07-28: Added and passed `listeningRecordBridgeProtocol.test.js`; registered it in the static suite. +- 2026-07-28: Selected a VM source-injection harness for vocab UI protocol coverage so production exports remain unchanged. +- 2026-07-28: Added and passed `reviewHighlightDictionaryProtocol.test.js`; registered it in the static suite. +- 2026-07-28: Began Browse consumer tracing; ruled out `browseController.js` as the completion-state owner. +- 2026-07-28: Located both Browse completion scans and the existing read-status regression; selected a shared `suiteEntrySummaries`-first helper with `suiteEntries` fallback. +- 2026-07-28: Implemented Browse `suiteEntrySummaries` consumption in both indexed and path/file fallback paths; `legacyViewReadStatus.test.js` passes. +- 2026-07-28: Verified the DataKernel → AppData backups → ExternalBackupService remote-commit subscription chain. +- 2026-07-28: Made the ExternalBackupService regression emit an explicit `remote:true` child-realm commit; the v2 backup suite passes. +- 2026-07-28: Located the app fallback origin regression surface in `practiceRecordPersistence.test.js`. +- 2026-07-28: Extended the fallback persistence regression to file:// REQUEST_INIT and ACK, asserting declared `null` and wildcard targetOrigin; the test passes. +- 2026-07-28: Started a repository-wide PRACTICE_COMPLETE/ACK sender audit. +- 2026-07-28: Sender audit found Practice Enhancer completion messages missing submissionId at the final send boundary. +- 2026-07-28: Completed sender inventory: Unified Reading and Listening are correlated; generic enhancer, injected collector, two templates, and the inline E2E fixture need metadata enrichment. +- 2026-07-28: Selected existing enhancer VM coverage and session-bound submission ID reset semantics for the generic/template fixes. +- 2026-07-28: Added completion correlation to Practice Enhancer and the host-injected collector; enhancer syntax and its 6/6 VM regressions pass. +- 2026-07-28: Added session-bound submission correlation and file-origin normalization to the shipped placeholder/base templates; placeholder replay test passes. +- 2026-07-28: Confirmed the unified E2E runner excludes Listening and found UA-based test-environment activation still permits synthetic saves under Playwright. +- 2026-07-28: Removed automation-UA test mode, added its explicit-opt-in regression, and passed syntax/unit checks. +- 2026-07-28: Added Listening to the unified E2E list; reading file submit and Listening now use a normal Chrome UA and assert production test-env/recorder/receipt/synthetic invariants. Python syntax checks pass. +- 2026-07-28: Added placeholder completion contract coverage; the replay/submit regression passes. +- 2026-07-28: Rebuilt all generated bundles successfully. The builder reported only the repository's eight known non-blocking symbol collisions. +- 2026-07-28: Focused data/kernel/backup, recorder/persistence/completion, Listening/vocab/environment, host/unified-reading, suite/enhancer/placeholder, and Browse/view test groups all pass. +- 2026-07-28: Replaced `codex/audit-tmp-migration` with the verified seven-commit `codex/squash-preview` history and force-pushed with an explicit remote lease; preserved `codex/audit-tmp-migration-backup` at the original 34-commit tip. +- 2026-07-28: Took over the new production reports: extracted packages sometimes show zero exams, optional Listening manifest is absent, and practice completion can fail v2 validation because `correctAnswers` reaches `canonicalizeRecord` as a negative/non-finite value. +- 2026-07-28: Started the manifest/submission hotfix trace; no source edits made yet. +- 2026-07-29: Three read-only traces confirmed the legacy IndexedDB row-envelope bug, the `exam_index` sentinel/active-library mismatch, the non-idempotent migration guard, and the overloaded `correctAnswers` completion contract. +- 2026-07-29: Local PowerShell spawning began failing globally with Windows `CreateProcessAsUserW error 5`; switched to the exact force-pushed branch through the connected GitHub read API for source verification. No code was edited through GitHub. +- 2026-07-29: Fixed legacy IDB `{key,value,timestamp}` unwrapping and added sessionStorage fallback with an explicit completeness signal. +- 2026-07-29: Added versioned, retry-safe v1-to-v2 repair: default sentinel translation, deterministic custom-library remap, poisoned active-state repair, current-v2 precedence, per-layer practice merge, stable missing-ID generation, and a completion marker written only after full success. +- 2026-07-29: Added completion-score normalization that preserves answer maps, selects the first valid non-negative scalar (including zero), derives totals from scoreInfo, and keeps canonical validation strict; applied the same rule to suite light summaries. +- 2026-07-29: Changed empty/damaged active custom-library startup to reset active state and continue through the generated Reading manifest path; optional Listening absence remains non-blocking. +- 2026-07-29: Added regressions in dataKernelV2, appDataV2, legacyMigrationBrickRegression, and libraryManagerImportConfig for the three production reports. +- 2026-07-29: Two independent verification agents confirmed the intended static paths and identified/fixed retry/idempotency/catch-boundary issues. Node execution and bundle rebuild remain blocked by the global Windows process-launch error; no hotfix commit or push was made. +- 2026-07-29: Inspected the supplied 4 KB backup completely and reproduced its semantics: checksum-valid full snapshot, zero practice rows, missing imported-index envelope, poisoned active ID, and three legacy row wrappers. +- 2026-07-29: Compared six opensource export paths and confirmed the catastrophic file is a v2 migration/export regression, while also documenting old-version backup completeness gaps. +- 2026-07-29: Confirmed current import accepts the poisoned file, replace clears all practice stores, and bad active state hides the manifest; started backup trust/import-safety implementation. +- 2026-07-29: Implemented semantic v2 import canonicalization for exact legacy row wrappers, sparse-snapshot degradation, cross-domain wrapper isolation, and all-or-nothing custom-library bundle validation. +- 2026-07-29: Removed the unsafe rule that interpreted missing full-snapshot envelopes as clears; new exports now produce a dense present/cleared catalog snapshot. +- 2026-07-29: Added preview-bound destructive confirmation tokens and exact practice existing/incoming/final/removed counts; wired ordinary import, external restore, and E2E callers. +- 2026-07-29: Added `poisonedV2Repair` startup recovery that runs independently of the old completion marker, retries legacy practice recovery, repairs safe document wrappers, and resets/reconstructs invalid active-library state. +- 2026-07-29: Rebuilt all 14 generated bundles; `build-bundles.mjs --check` passes. Focused AppData/DataKernel/migration/library/external-backup/recorder suites pass. +- 2026-07-29: file:// export/import Playwright flow passes end-to-end, including v1 merge and v2 destructive replace with confirmation token. +- 2026-07-29: The exact user-supplied poisoned JSON passed a dedicated file:// browser safety run: merge preserved the seeded record/current library and replace reported `1 → 0` then rejected a tokenless commit. +- 2026-07-29: Full JS sweep passed all data/import-related suites; the unrelated `unifiedReadingCoreRegression.test.js` async notification-order assertion remains reproducibly failing. +- 2026-07-29: Started a raw-data-first migration-chain review at the user's request; no new fallback code will be added until the original persisted shapes and every lossy boundary are re-established. +- 2026-07-29: Three independent read-only audits completed: historical writer shapes, byte-to-domain loss tracing, and current-patch minimality. They converged on one root cause and identified multiple over-broad recovery risks for main-agent line-level verification. +- 2026-07-29: Main-agent source checks confirmed the canonical old IDB writer/reader pair, real unprefixed Web Storage variants, the absence of a production raw-IDB writer, and the new forceReload overwrite bug. +- 2026-07-29: Removed the unevidenced raw-IDB fallback, added explicit unprefixed Web Storage keys, unified wrapper decoding, preserved post-migration overlay fields, and rejected non-object wrapper payloads. +- 2026-07-29: Replaced global marker-triggered replay with fresh-or-poison detection, removed the independent poisoned-repair marker, prevented resurrection from healthy v2 + frozen v1, and simplified destructive authorization to `confirmDestructive`. +- 2026-07-29: Added real writer-envelope coverage through the complete IndexedDB → AppData migration chain, plus regressions for no resurrection, partial library imports, and healthy custom-library force reload. +- 2026-07-29: Limited poison-time library recovery to the exact wrapped legacy index IDs; a poisoned active pointer can consult its old ID only when v2 has no usable current index. +- 2026-07-29: Verified the supplied poisoned backup in a real file:// browser: merge is degraded to partial and preserves a seeded record/current library; replace is destructive and rejects commit without `confirmDestructive:true`. +- 2026-07-29: Rebuilt all 14 bundles and passed focused data/import/library tests plus the file:// export/import E2E. Full JS sweep passed 50/51; only the unchanged standalone Unified Reading notification-order assertion failed. +- 2026-07-29: Completed final bundle drift and `git diff --check` verification. No hotfix commit or push was made. +- 2026-07-29: User changed the migration priority: surviving v1 data must be reconciled automatically on every startup and must overwrite known-poisoned v2 values. Started a new persistent-reconciliation phase. +- 2026-07-29: Three independent read-only audits located the exact marker/fresh/poison gates, proposed the persistent union precedence, and identified the existing no-resurrection tests that must be reversed. +- 2026-07-29: Main-agent source review confirmed the existing three-layer practice helper can atomically replace partial records and the library checksum comparisons can provide repeated-startup idempotency. +- 2026-07-29: Implemented the first persistent-reconciliation source pass: legacy reads are no longer gated by markers/healthy v2, libraries are unioned, missing documents are seeded, partial practice records are atomically repaired, and the stable marker is diagnostic only. +- 2026-07-29: AppData syntax check passed; the old migration regression now fails only at its expected retired `poison-repair` mode assertion. +- 2026-07-29: Reversed the obsolete no-resurrection tests and added a shared-backing reboot harness with mutation counters and entity revisions. +- 2026-07-29: Persistent reconciliation regression passes, including marker bypass, v1/v2 union, poisoned wrapper precedence, active-library recovery, partial-record repair, and second-boot zero-write idempotency. +- 2026-07-29: DataKernel, AppData, LibraryManager, and ExternalBackupService focused suites all pass after the reconciliation change. +- 2026-07-29: Extended the real browser/IndexedDB migration test across three realms: marker bypass and newly added v1 data on boot two, followed by zero business revision/checksum churn on boot three. The test passes. +- 2026-07-29: Added operation-ID based recovery for collapsed legacy array/object documents and covered a collapsed `vocab.words` envelope. +- 2026-07-29: Added an explicit persistent-union regression: deleting a v1-backed practice record from v2 causes exactly one atomic restoration on the next startup. +- 2026-07-29: Persistent-reconciliation implementation and regressions are complete; focused VM and real IndexedDB migration tests pass. +- 2026-07-29: Rebuilt all 14 generated bundles after the persistent-reconciliation source change; only the eight known non-blocking symbol conflicts remain. +- 2026-07-29: Focused DataKernel, persistent migration, AppData, LibraryManager, and ExternalBackupService suites all pass on the rebuilt source. +- 2026-07-29: file:// export/import E2E passes after persistent reconciliation, including v1 merge, visible history, and confirmed v2 replace. +- 2026-07-29: Full JS sweep passed 50/51. All migration/data/import/library tests pass; the same unchanged Unified Reading notification-order assertion remains the sole failure. +- 2026-07-29: Extended persistent reconciliation from missing/poisoned documents to catalog-aware unions for all valid v1 `patch` and `merge-by-id` documents; focused migration, real IndexedDB, and AppData suites pass. +- 2026-07-29: Rebuilt bundles again after the document-union extension. Final full JS sweep remains 50/51 with only the unchanged Unified Reading notification-order failure. +- 2026-07-29: Final file:// export/import E2E passes on the persistent-union build. +- 2026-07-29: Final bundle drift check confirms all 14 outputs are current; `git diff --check` passes. Persistent v1 reconciliation is complete and remains uncommitted/unpushed. +- 2026-07-30: Started the review/v2-insights/endless-mode change, read the planning skill and supplied automated-review report, and recovered the prior persistent-reconciliation context. +- 2026-07-30: Three read-only audits confirmed all six review regressions, the light-summary/detail mismatch behind empty wrong-answer classification, and the deterministic null-state crash that prevents endless mode startup. +- 2026-07-30: Recorded the current five-phase implementation plan and retained all existing uncommitted migration/bundle work. +- 2026-07-30: Read the project README and the exact catalog/summary/radar/import/endless/open-exam source regions that define the affected contracts. +- 2026-07-30: Located focused regression surfaces for achievement projection, Browse hydration, import sequencing, and the static method contract. +- 2026-07-30: Implemented the first source pass for all six review findings, lightweight question-type error projections, suite-aware filtering/radar input, and the unified endless-mode exam-open lifecycle. +- 2026-07-30: All changed JavaScript and Python files pass syntax checks; `git diff --check` is clean apart from expected CRLF conversion warnings. +- 2026-07-30: Added executable regressions for transient recovery non-resurrection, durable achievements, Browse hydration readiness, light/suite error-count insights, suite type filtering, and the first/next endless exam lifecycle. +- 2026-07-30: AppData v2 (47 tests), persistent migration, Browse preferences (5/5), practice custom card (9/9), and Unified Reading/endless lifecycle focused suites all pass. +- 2026-07-30: Corrected the remaining Browse first-render gap by awaiting preference hydration alongside the active exam-index load in `initializeBrowseView()`. +- 2026-07-30: The first unified static-suite run exceeded the 120-second shell bound with no emitted failure; recorded the timeout and deferred the longer rerun until after the required final bundle rebuild. +- 2026-07-30: Rebuilt all 14 bundles; bundle drift check and all focused tests pass on generated outputs. +- 2026-07-30: The unified static suite exceeded a 300-second outer timeout; source inspection confirmed several intentional 240s/360s/480s child gates, so the final attempt will use an outer bound that covers the runner's own declared timeouts. +- 2026-07-31: Full static report completed. All changed-feature gates pass; remaining failures are the pre-existing v2 legacy-key allowlist mismatch, noisy suite-test JSON parsing, four NB replay content cases, and the 480-second Reading quick audit timeout. +- 2026-07-31: Added bounded `practice.listInsights({limit:10})` compatibility reads so historical summaries also feed the wrong-answer radar without scanning annotations or all details; focused AppData and light-render tests pass. +- 2026-07-31: First suite E2E attempt stopped before the reviewed preference assertion because an overview re-render detached the button during an explicit scroll; replaced that redundant scroll with Playwright's locator auto-wait path. +- 2026-07-31: Second suite E2E attempt reached preference setup and exposed one stale Playwright positional-argument call; converted it to the current keyword-only `arg=` API. +- 2026-07-31: Third suite E2E attempt passed lazy loading and preference setup, then hit the existing first-passage readiness timeout caused by unavailable local exercise assets; stopped expanding that unrelated browser fixture path. +- 2026-07-31: Final focused verification passes for suite preferences, DataKernel, AppData, external backups, light render contracts, migration, Browse preferences, practice insights, and executable endless lifecycle. +- 2026-07-31: Final syntax checks, 14-bundle drift check, exam-app method contract, and `git diff --check` all pass. Current change is complete and remains uncommitted/unpushed. +- 2026-07-31: Started a read-only residual-gate triage at the user's request; recovered the prior plan/worktree state and separated the four static failures plus suite E2E readiness into independent audits. +- 2026-07-31: Completed three independent audits and main-agent line verification. Classified the v2 guard, suite JSON parser, and four NB replay failures as stale test infrastructure; classified Reading quick and suite placeholder propagation as unresolved end-to-end coverage blockers. +- 2026-07-31: Confirmed the missing listening manifest is optional noise rather than the suite button root cause. No production or test implementation was changed during this diagnostic pass. +- 2026-07-31: User authorized implementation. Recovered the persistent plan and dirty worktree, opened a five-phase residual-gate repair, and retained the rule that runtime message safety must not be weakened to satisfy stale fixtures. +- 2026-07-31: Three clean-context read-only agents completed exact reconnaissance for gate/fixture, Reading, and suite repairs. Chosen design uses the real Reading host/ACK protocol, one-process dataset export, narrow suite flag propagation, and trusted NB messages. +- 2026-07-31: Main-agent inspection verified the exact semantic allowlist markers and current NB/date-sensitive fixture code before editing. +- 2026-07-31: Repaired the v2 semantic guard and suite last-line JSON collection. The guard now reports zero source/test/bundle/html errors and suiteModeRegression exits successfully. +- 2026-07-31: Updated NB replay to use trusted INIT/token/source and clone-scoped selectors; all 4/4 generated-resource cases pass without weakening runtime security. +- 2026-07-31: Replaced the fixed migration timestamp with fresh/stale relative rows; the persistent migration regression passes and explicitly validates 30-day TTL pruning. +- 2026-07-31: Reading exporter now returns all 232 datasets from one Node/VM process; syntax/count validation passes. +- 2026-07-31: Reading quick now exercises the real iframe INIT/SESSION_READY/PRACTICE_COMPLETE/ACK chain. Static coverage passed 232/232 and UI coverage passed 12/12 in 22.3 seconds instead of timing out at 480 seconds. +- 2026-07-31: Added narrow `suite_test=1` propagation, URL encoding regression coverage, and immediate E2E blocked-state diagnostics; the source-level suite regression passes. +- 2026-07-31: Rebuilt all 14 bundles; bundle drift check passes with the same eight known non-blocking symbol conflicts. +- 2026-07-31: Suite E2E now launches the unlocked placeholder, completes P1, and switches to P2. It then times out after P2 submit while waiting for P3, exposing a deeper transition defect that was previously masked by the blocked placeholder. +- 2026-07-31: Added suite sequence exam definitions to every subsequent `openExam()` call and bound placeholder simulation navigation to its current session ID; these preserve the locked sequence and provide the strict fallback routing proof. +- 2026-07-31: Hardened suite E2E GPL overlay dismissal against asynchronous modal appearance after an initial app-ready check. +- 2026-07-31: Stabilized suite placeholder URL fallbacks for both stationary and simulation flows; late INIT/REVIEW_CONTEXT messages no longer erase manual navigation or final-submit state. +- 2026-07-31: Final suite E2E passes automatic three-passage aggregation plus stationary manual review/finalization (180.5s). Reading quick 232/232 + 12/12, NB 4/4, migration, suite-mode, and unified readonly-submit regressions also pass. +- 2026-07-31: Full unified static suite passes after adding the missing `listInsights()` method to the practice-persistence test harness; all gates are green, with only documented optional/skipped checks and eight non-blocking historical bundle symbol warnings. diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 00000000..6cf4d753 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,232 @@ +# AppData v2 Audit Gate Implementation + +## Goal + +Implement the approved data integrity, file:// protocol, import/projection, idempotency/concurrency, cross-realm notification, and regression-test gates on `codex/audit-tmp-migration` while preserving the user's existing accuracy UI changes. + +## Constraints + +- Preserve existing worktree changes in: + - `js/components/practiceRecordModal.js` + - `js/views/legacyViewBundle.js` + - generated `js/bundles/browse.bundle.js` + - generated `js/bundles/practice.bundle.js` +- Keep AppData IDB-only; do not add a long-lived legacy backend. +- Subagents are read-only scouts; main agent owns edits and final verification. +- Generated bundles must be rebuilt from source after source changes. + +## Phases + +1. **Baseline and contracts** — complete + - Capture current branch/diff/worktree state. + - Locate exact protocol/data/test surfaces and delegate independent read-only checks. +2. **Data kernel and AppData** — complete + - Full mirror restore, entity-layer invariants, journal reset. + - Legacy projection normalization, suite light summaries, operation IDs. + - Vocab CAS retry/serialization and corruption isolation. + - Cross-realm commit broadcast. +3. **Messaging protocols** — complete + - file:// fallback origin. + - Canonical fallback recorder. + - Listening submission correlation/ACK retry. + - Vocab save ACK. +4. **Tests and bundles** — complete + - Update/add focused unit and Playwright coverage. + - Rebuild bundles without losing existing UI source edits. +5. **Integration verification** — complete + - Run focused and full available suites. + - Fetch latest `origin/opensource`; integrate only if safe with the dirty worktree. + - Review final diff and report residual risks. + +## Current Hotfix: Manifest Loading And Practice Submission + +### Goal + +Restore reliable `file://` operation by making the generated reading manifest the only built-in exam-index source and by preventing valid practice completions from producing an invalid negative/non-finite `correctAnswers` value. + +### Phases + +1. **Trace exact failure paths** — complete + - Locate every reading exam-index source and the zero-index fallback path. + - Trace completion payload normalization into `AppData.practice.completeAttempt`. + - Separate optional missing Listening assets from Reading startup and submission. +2. **Regression coverage** — complete + - Pin manifest-only built-in loading under `file://`. + - Pin score normalization for the reported completion payload shape. +3. **Source fixes and bundle rebuild** — complete + - Apply narrowly scoped source changes. + - Rebuild all generated bundles once from the final source tree. + - Source changes and generated bundles are synchronized. +4. **Verification** — complete + - Run focused and full relevant JS suites. + - Run bundle drift/syntax checks and the available `file://` submission flow. + +## Current Hotfix: Backup Trust And Import Safety + +### Goal + +Prevent semantically poisoned or sparse v2 backups from clearing practice records or hiding the built-in Reading manifest, while preserving recoverable user settings and maintaining explicit destructive restore semantics. + +### Phases + +1. **Real-backup reproduction and opensource comparison** — complete + - Inspect the supplied backup byte-for-byte and verify its checksum. + - Compare old export/import paths and identify v2-only regressions. +2. **Semantic snapshot validation and salvage** — complete + - Repair known legacy row wrappers only when aliases match. + - Validate the library configuration/index/active-ID bundle as one unit. + - Classify declared/effective scope and surface repaired/missing keys. +3. **Destructive import guard** — complete + - Compute existing/incoming/final/removed practice counts. + - Require explicit `confirmDestructive:true` after the UI confirmation before destructive commit. + - Update both ordinary import and external restore confirmations. +4. **Dense export and round-trip coverage** — complete + - Materialize every exportable catalog key as present or explicitly cleared. + - Add the supplied poisoned snapshot as a regression fixture. +5. **Bundle rebuild and end-to-end verification** — complete + - Fix existing test expectation drift, run focused/full suites, rebuild bundles once, and verify `file://` import/browse behavior. + +## Current Review: Raw-Data Migration Chain + +### Goal + +Re-audit the complete v1-to-v2 path from original persisted bytes, distinguish root-cause corrections from defensive recovery code, and simplify any fallback that is not justified by a demonstrated historical data shape. + +### Phases + +1. **Historical source-of-truth inventory** — complete + - Enumerate every v1 writer and the exact physical IndexedDB/localStorage shapes. + - Separate authoritative user records from generated/default manifest caches. +2. **Byte-to-domain migration trace** — complete + - Replay representative raw rows through read, parse, normalize, mutate, export, and import. + - Record every lossy or shape-changing boundary. +3. **Current patch minimality review** — complete + - Classify each new recovery/import safeguard as root fix, required compatibility, or removable overengineering. + - Prefer preventing the first bad write over repairing arbitrary poisoned states. +4. **Evidence and decision** — complete + - Add only narrowly justified tests or corrections. + - Report the canonical migration contract and remaining unrecoverable cases. + +## Current Change: Persistent v1 Reconciliation + +### Goal + +Treat surviving v1 user data as the authoritative recovery source on every startup: merge all valid v1 records and user-library data into v2, and overwrite only v2 values carrying the known bad-migration fingerprints. + +### Phases + +1. **Reconciliation contract** — complete + - Define document, practice, library, and repeated-startup precedence. + - Preserve valid v2-only additions while ensuring all v1 records are present. +2. **Implementation and regressions** — complete + - Remove the marker/healthy-v2 early exits that suppress legacy reconciliation. + - Add repeated-startup, damaged-v2 overwrite, and mixed v1/v2 merge coverage. +3. **Bundles and verification** — complete + - Rebuild generated bundles. + - Run focused migration/import/library suites, file:// E2E, bundle drift, and diff checks. + +## Current Change: Review Fixes, v2 Insights, And Endless Mode + +### Goal + +Fix the six confirmed automated-review regressions, reconnect practice-record error classification to a lightweight v2 projection, and restore the complete endless-reading lifecycle without regressing the existing persistent v1 reconciliation work. + +### Phases + +1. **Evidence and contracts** — complete + - Confirm every review finding against the current source and tests. + - Trace the light-summary/detail split used by practice insights. + - Trace endless startup, navigation, completion, next-exam, and cleanup. +2. **Review fixes** — complete + - Stop recurring reconciliation of transient recovery documents. + - Await Browse preference hydration before first UI/scroll restoration. + - Repair the method-contract scanner, achievement durability, async E2E assertion, and pre-import backup timing. +3. **Lightweight practice insights** — complete + - Project compact question-type error counts into v2 summaries and suite-entry summaries. + - Teach the practice priority/radar consumer to use the compact projection. + - Use existing suite-entry summaries for exam-type filtering. +4. **Endless mode lifecycle** — complete + - Fix first-start state construction. + - Carry an explicit endless marker through the unified exam-open path. + - Reuse the normal session lifecycle for subsequent exams and make startup failures visible/clean. +5. **Regression coverage, bundles, and verification** — complete + - Add focused unit/contract/E2E coverage for every changed behavior. + - Rebuild generated bundles once from final source. + - Run focused suites, static suite, relevant E2E, bundle drift, and diff checks. + +## Current Audit: Residual Gate Triage + +### Goal + +Determine whether each residual unified-static/E2E failure represents a product defect that should be fixed, a test-runner defect worth repairing, or an optional resource-dependent audit that should be isolated from the default gate. + +### Phases + +1. **Independent evidence collection** — complete + - Audit the v2 legacy-key guard and suite JSON parser. + - Reproduce and classify the four NB replay failures. + - Trace the Reading quick timeout and suite first-passage readiness failure. +2. **Main-agent verification** — complete + - Check agent-provided file/line evidence and rerun the smallest decisive probes. + - Estimate blast radius and implementation cost. +3. **Recommendation** — complete + - Rank required, recommended, and optional fixes. + - Do not modify production or test code in this diagnostic turn. + +## Current Implementation: Residual Gate Repair + +### Goal + +Repair the stale static/test gates, restore deterministic Reading quick coverage, propagate suite test mode into the placeholder path, rebuild affected bundles, and verify the complete chains without weakening runtime safety. + +### Phases + +1. **Fresh source/test reconnaissance** — complete + - Locate exact minimal edits for the static allowlist, suite JSON parsing, NB trusted-message fixture, Reading ready/result contract, batch dataset loading, and suite placeholder URL. + - Preserve the existing dirty worktree and prior implementation. +2. **Infrastructure and fixture repair** — complete + - Repair semantic allowlists and last-line JSON parsing. + - Update NB replay setup and clone-group selection. + - Remove the date-sensitive legacy migration fixture. +3. **Reading and suite chain repair** — complete + - Establish a deterministic Reading ready/result assertion and eliminate per-dataset Node cold starts. + - Propagate the narrow suite test flag and add immediate E2E diagnostics. +4. **Bundles and focused verification** — complete + - Rebuild only from final source using the repository build path. + - Run focused JS/Python/E2E tests and bundle drift checks. +5. **Full gate verification** — complete + - Run the unified static suite with a realistic outer bound. + - Record any remaining unrelated failures without masking them. + +## Errors Encountered + +| Error | Attempt | Resolution | +|---|---:|---| +| Subagent tools returned `unsupported call` during the prior audit turn, Default mode, and the resumed implementation turn | 4 | Stop retrying the unavailable interface; use parallel read-only shell probes and record the limitation | +| Parallel gate probe assumed a root `package.json`; PowerShell redirection also made `rg.exe` fail | 1 | Locate manifests with `rg --files` first; run probes with per-call error capture and no stderr redirection | +| Bundled `rg.exe` subsequently failed to launch with Windows `Access denied` | 1 | Treat `rg` as unavailable for this session; use `git ls-files`, `git grep`, and `Select-String` | +| Combined AppData/DataKernel patch missed the exact `createRestoreSnapshot` context and was rejected atomically | 1 | Split into smaller exact hunks after rereading the current function; no source changes were applied | +| New corruption test asserted `summary.id`, but the seeded legacy test summary only contains `title/score` | 1 | Assert the surviving row by `title`; implementation behavior was correct | +| Submission contract hunk missed an intervening `observedOrigin` assignment | 1 | Reread the 12-line target and inserted the guard immediately before message metadata is committed | +| Fallback ACK regression kept Node alive on the 120-second receipt replay timer | 1 | Preserve the browser timer and call `unref()` only when the runtime timer supports it | +| PowerShell regex quoting failed while locating suite completion fixtures | 1 | Switched to `Select-String -SimpleMatch`; no source action was repeated | +| Resumed subagent dispatch still returned `unsupported call` | 5 | Honor the existing stop condition; continue with bounded read-only source probes | +| `suiteModeRegression` counted the completion-time recorder rebind as a reset-time rebind | 1 | Clear the fixture's `recorderStarts` probe immediately before sending the reset request | +| Multi-file PowerShell range printer hit an array type mismatch after printing the first targets | 1 | Retain the useful output and switch to exact `Select-String`/single-file reads for remaining senders | +| PowerShell parsed unquoted `^{tree}` revisions incorrectly during squash replacement preflight | 1 | Safety check aborted before mutation; reran with quoted revisions, verified identical tree hashes, then force-pushed with an explicit lease | +| All PowerShell/Node child processes fail before startup with `CreateProcessAsUserW failed: 5` | 3 execution paths + 4 agents | Switched to remote exact-tree reads and static review; source/tests are patched, but tests and bundle rebuild must wait for the desktop sandbox/process launcher to recover | +| Full JS sweep exposed `unifiedReadingCoreRegression.test.js` notification-order failure | 2 | Reproduced alone; unrelated to the data/import files changed here and recorded as a pre-existing residual failure | +| Existing file:// E2E called destructive `commitImport` without the new preview token | 1 | Updated the test to model the same explicit confirmation-token handoff as production UI; rerun passed | +| Combined plan/findings status patch targeted a findings heading in `task_plan.md` | 1 | Atomic patch made no changes; split the update across the correct files | +| Legacy migration regression still expected the retired `poison-repair` marker mode | 1 | Source syntax passed; update the test contract to persistent reconciliation before rerunning | +| PowerShell range probe accidentally assigned inside the loop condition | 1 | Parser rejected before execution; reran with a fixed numeric upper bound | +| Reboot harness treated delete entity operations as upserts and checksummed `undefined` | 1 | Added the harness delete branch so the persistent-restoration test exercises the real three-layer delete contract | +| Planning skill completion helper reported `0/4` because this long-lived plan uses prose phase markers rather than its checkbox template | 1 | Manually verified and marked the current and overall verification phases complete; did not rewrite the established planning format | +| Bundled `rg.exe` still fails to launch with Windows `Access denied` during the current change | 2 | Reuse the established fallback: `git grep`, `git ls-files`, and PowerShell `Select-String`; do not retry `rg` | +| Unified static suite exceeded the initial 120-second command timeout without producing a failure report | 1 | Build final bundles first, then rerun the suite with its realistic longer timeout instead of repeating the same bound | +| Unified static suite also exceeded a 300-second outer shell timeout | 2 | Inspection shows the runner legitimately contains 240s/360s/480s child-test bounds and emits only at completion; rerun once with an outer bound covering those declared gates | +| `suite_practice_flow.py` retained a locator across an overview re-render and failed while scrolling a detached button | 1 | Replace the redundant explicit scroll with Playwright's visible wait and click auto-retry on the locator | +| Suite E2E used the pre-keyword-only Playwright `wait_for_function` argument form in preference setup | 1 | Pass the payload through the current `arg=` keyword, matching every other parameterized wait in the file | +| Cleanup of the newly generated `developer/tests/ci/__pycache__` was blocked by the desktop command policy | 1 | Leave the untracked cache untouched and report it; no retry or broader deletion | +| Suite E2E passed placeholder launch and P1→P2, then timed out waiting for P2→P3 | 1 | Treat as newly exposed chain defect; inspect the exact transition/report rather than raising the 20-second wait | +| Suite E2E later failed before suite launch because the asynchronously shown GPL modal intercepted browse navigation | 1 | Make overlay dismissal wait for visible state instead of a one-shot `.show` count check | From 00bb3644d1fbc8a8b2be32727fc44a20453715b2 Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 11:35:39 +0800 Subject: [PATCH 16/18] fix: accept Linux file origins in suite child --- js/app/examSessionMixin.js | 4 ++-- js/bundles/browse.bundle.js | 4 ++-- templates/exam-placeholder.html | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/js/app/examSessionMixin.js b/js/app/examSessionMixin.js index 58ba228f..84179ef2 100644 --- a/js/app/examSessionMixin.js +++ b/js/app/examSessionMixin.js @@ -1507,7 +1507,7 @@ state.parentOrigin = expectedParentOrigin; state.parentOriginIsOpaque = false; } else if (window.location.protocol === 'file:') { - var trustedFileOrigin = incomingOrigin === 'null' + var trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://') && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); if (!trustedFileOrigin) return; state.parentOrigin = 'null'; @@ -1532,7 +1532,7 @@ : ''; var messageOrigin = event && typeof event.origin === 'string' ? event.origin : ''; var originMatches = state.parentOriginIsOpaque - ? messageOrigin === 'null' + ? (messageOrigin === 'null' || messageOrigin === 'file://') : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin); if (!event || event.source !== parentWindow || message.source !== 'exam_host' || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) { diff --git a/js/bundles/browse.bundle.js b/js/bundles/browse.bundle.js index 48601863..4d22c3ea 100644 --- a/js/bundles/browse.bundle.js +++ b/js/bundles/browse.bundle.js @@ -8395,7 +8395,7 @@ state.parentOrigin = expectedParentOrigin; state.parentOriginIsOpaque = false; } else if (window.location.protocol === 'file:') { - var trustedFileOrigin = incomingOrigin === 'null' + var trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://') && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); if (!trustedFileOrigin) return; state.parentOrigin = 'null'; @@ -8420,7 +8420,7 @@ : ''; var messageOrigin = event && typeof event.origin === 'string' ? event.origin : ''; var originMatches = state.parentOriginIsOpaque - ? messageOrigin === 'null' + ? (messageOrigin === 'null' || messageOrigin === 'file://') : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin); if (!event || event.source !== parentWindow || message.source !== 'exam_host' || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) { diff --git a/templates/exam-placeholder.html b/templates/exam-placeholder.html index 50ae368d..1d362cc7 100644 --- a/templates/exam-placeholder.html +++ b/templates/exam-placeholder.html @@ -1286,7 +1286,7 @@

练习说明

state.parentOrigin = expectedOrigin; state.parentOriginIsOpaque = false; } else if (window.location.protocol === 'file:') { - const trustedFileOrigin = incomingOrigin === 'null' + const trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://') && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); if (!trustedFileOrigin) return; state.parentOrigin = 'null'; @@ -1361,7 +1361,7 @@

练习说明

const incomingOrigin = typeof event.origin === 'string' ? event.origin : ''; const incomingToken = typeof data.windowSessionToken === 'string' ? data.windowSessionToken.trim() : ''; const originMatches = state.parentOriginIsOpaque - ? incomingOrigin === 'null' + ? (incomingOrigin === 'null' || incomingOrigin === 'file://') : Boolean(state.parentOrigin && incomingOrigin === state.parentOrigin); if (event.source !== opener || envelope.source !== 'exam_host' || !originMatches || !state.windowSessionToken || incomingToken !== state.windowSessionToken) { From 33e61da678d12db3ad538f3ebff5ff454af3f88e Mon Sep 17 00:00:00 2001 From: sallowayma-git Date: Wed, 5 Aug 2026 17:21:51 +0800 Subject: [PATCH 17/18] refactor: simplify v2 migration and reset flows --- .github/workflows/ci.yml | 4 +- developer/tests/js/appDataV2.test.js | 1 + developer/tests/js/dataKernelV2.test.js | 10 +- developer/tests/js/dataLossBaseline.test.js | 8 + .../js/legacyMigrationBrickRegression.test.js | 314 +-- ...cticeLightProjectionRenderContract.test.js | 7 + developer/tests/js/siteDataReset.test.js | 852 +------- js/app.js | 56 +- js/app/examSessionMixin.js | 54 +- js/bundles/browse.bundle.js | 63 +- js/bundles/core-foundation.bundle.js | 1779 ++++------------- js/bundles/legacy-app.bundle.js | 56 +- js/bundles/listening-record-bridge.bundle.js | 1006 +++------- js/bundles/listening-wrapper.bundle.js | 1004 +++------- js/bundles/practice-page-enhancer.bundle.js | 1002 +++------- js/bundles/reading-page.bundle.js | 1022 +++------- js/core/siteDataReset.js | 773 +------ js/data/v2/appData.js | 1004 +++------- js/main.js | 9 - js/runtime/unifiedReadingPage.js | 16 - 20 files changed, 1932 insertions(+), 7108 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 787c4753..b8ecc4ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: run: | npm ci --prefix developer python -m pip install --upgrade pip - python -m pip install playwright==1.56.0 - npm --prefix developer exec -- playwright install --with-deps chromium + python -m pip install playwright + python -m playwright install --with-deps chromium - name: Check JS bundle drift # The committed files under js/bundles/ must match what the build script diff --git a/developer/tests/js/appDataV2.test.js b/developer/tests/js/appDataV2.test.js index 8b4ac1fb..4da0bf0f 100644 --- a/developer/tests/js/appDataV2.test.js +++ b/developer/tests/js/appDataV2.test.js @@ -29,6 +29,7 @@ function harness() { async journalNoop(options = {}) { return { committed: true, operationId: options.operationId || `noop-${++shared.counter}`, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; } async readEntity(store, recordId, options = {}) { shared.reads.push(store); const row = shared.entities.get(store).get(String(recordId)) || null; return options.withMeta ? clone(row) : row && clone(row.data); } async listEntities(store, options = {}) { if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); shared.lists.push(store); const rows = Array.from(shared.entities.get(store).values()); return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); } + async readPracticeSnapshot(recordIds = null, options = {}) { const ids = recordIds === null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; const result = {}; for (const store of stores) { if (ids) shared.reads.push(store); const rows = Array.from(shared.entities.get(store).values()).filter((row) => !ids || ids.has(String(row.recordId))); result[store] = options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); } return result; } async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); const revisions = {}; const next = new Map(Array.from(shared.entities, ([store, rows]) => [store, new Map(rows)])); for (const item of operations) { if (shared.failEntityStore === item.store) throw new AppDataError('IO', `forced entity failure: ${item.store}`); const rows = next.get(item.store); if (item.type === 'clear') { rows.clear(); revisions[`${item.store}/*`] = 0; continue; } const old = rows.get(String(item.recordId)); if (item.expectedRevision !== undefined && item.expectedRevision !== null && Number(item.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'entity revision'); if (item.type === 'delete') { rows.delete(String(item.recordId)); revisions[`${item.store}/${item.recordId}`] = Number(old && old.revision || 0) + 1; } else { const row = { recordId: String(item.recordId), revision: Number(old && old.revision || 0) + 1, operationId: op, updatedAt: new Date().toISOString(), data: clone(item.data), checksum: checksum(item.data) }; rows.set(row.recordId, row); revisions[`${item.store}/${item.recordId}`] = row.revision; } } shared.entities = next; shared.mutations.push(clone(operations)); return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; } async exportSnapshot(options = {}) { const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys) : null; diff --git a/developer/tests/js/dataKernelV2.test.js b/developer/tests/js/dataKernelV2.test.js index af1515d8..c3b40b9b 100644 --- a/developer/tests/js/dataKernelV2.test.js +++ b/developer/tests/js/dataKernelV2.test.js @@ -389,9 +389,8 @@ async function main() { assert.match(migrated.activeId, /^legacy-library-/); assert.equal(migrated.activeIndex[0].id, 'legacy-custom-exam'); - // A completed reconciliation marker never suppresses the next startup. - // Add one v2-only record and then append a new row to the surviving v1 - // source; the next realm must keep the former and migrate the latter. + // The completion marker makes v1 a one-time source. A later v1 write must + // not resurrect data after the user has moved on to v2. await page.evaluate(async () => { await window.AppData.practice.completeAttempt({ operationId: 'persistent-v2-only', @@ -433,12 +432,11 @@ async function main() { v2Only: await window.AppData.practice.get('persistent-v2-only'), migration: (await window.AppData.backups.export({ scope: 'partial', logicalKeys: [] })).schemaVersion })); - assert.equal(secondBoot.legacyAdded.correctAnswers, 1); + assert.equal(secondBoot.legacyAdded, null); assert.equal(secondBoot.v2Only.answers.q1, 'V2'); assert.equal(secondBoot.migration, 2); - // A third realm still reads v1, but an unchanged union produces no - // document or entity revision/checksum churn. + // Later boots remain stable and do not churn v2 business state. const secondBusinessState = await persistedBusinessState(page); await page.reload(); await loadAppData(page); diff --git a/developer/tests/js/dataLossBaseline.test.js b/developer/tests/js/dataLossBaseline.test.js index e62c65a4..bc991a87 100644 --- a/developer/tests/js/dataLossBaseline.test.js +++ b/developer/tests/js/dataLossBaseline.test.js @@ -166,6 +166,14 @@ function createHarness() { return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); } + async readPracticeSnapshot(recordIds = null, options = {}) { + const ids = recordIds == null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); + const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + return Object.fromEntries(stores.map((store) => [store, Array.from(shared.entities.get(store).values()) + .filter((row) => !ids || ids.has(String(row.recordId))) + .map((row) => options.withMeta ? clone(row) : clone(row.data))])); + } + async mutateEntities(operations, options = {}) { const operationId = String(options.operationId || `entity-${++idCounter}`); const fingerprint = checksum({ operations, warnings: options.warnings || [] }); diff --git a/developer/tests/js/legacyMigrationBrickRegression.test.js b/developer/tests/js/legacyMigrationBrickRegression.test.js index a87eeec4..2829700f 100644 --- a/developer/tests/js/legacyMigrationBrickRegression.test.js +++ b/developer/tests/js/legacyMigrationBrickRegression.test.js @@ -18,51 +18,25 @@ const recordSource = fs.readFileSync(path.join(root, 'js/data/practiceRecordSour const clone = (value) => value === undefined ? undefined : structuredClone(value); function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; return JSON.stringify(value); } function checksum(value) { let hash = 0x811c9dc5; for (const char of stable(value)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 0x01000193); } return `fnv1a-${(hash >>> 0).toString(16)}`; } -function parseLegacyValue(value) { let parsed = clone(value); for (let depth = 0; depth < 3; depth += 1) { if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { break; } } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) parsed = parsed.data; else break; } return clone(parsed); } class AppDataError extends Error { constructor(code, message) { super(message); this.code = code; } } -function harness(legacyValues, seedDocuments = {}, options = {}) { +function harness(legacyValues, options = {}) { const catalogSandbox = { structuredClone }; catalogSandbox.globalThis = catalogSandbox; vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' }); const catalog = catalogSandbox.__AppDataV2Catalog; + const shared = options.shared || { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), counter: 0, legacyReads: 0, externalReads: 0 }; const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({ schemaVersion: 2, revision, operationId, updatedAt: new Date().toISOString(), state, data: state === 'cleared' ? null : clone(data), checksum: checksum(state === 'cleared' ? null : data) }); - const shared = options.shared || { - docs: new Map(), - entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), - counter: 0, - legacyReadCount: 0, - externalReadCount: 0, - documentMutationCount: 0, - entityMutationCount: 0 - }; - if (!options.shared) { - for (const [key, data] of Object.entries(seedDocuments)) { - shared.docs.set(key, envelope(key, data, 'present', 1, options.seedDocumentOperationIds?.[key] || 'seed')); - } - for (const [store, rows] of Object.entries(options.seedEntities || {})) { - for (const [recordId, data] of Object.entries(rows)) { - shared.entities.get(store).set(recordId, { - recordId, - revision: 1, - operationId: 'seed', - updatedAt: new Date().toISOString(), - data: clone(data), - checksum: checksum(data) - }); - } - } - } class Kernel { async initialize() { this.state = 'ready'; this.backend = 'memory'; return this; } async getEnvelope(key) { return shared.docs.get(key) || null; } async read(key, options = {}) { const entry = catalog.get(key); const value = shared.docs.get(key) || null; const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data; return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data); } - async mutate(changes, options = {}) { const op = String(options.operationId || `doc-${++shared.counter}`); const revisions = {}; shared.documentMutationCount += 1; for (const change of changes) { const old = shared.docs.get(change.logicalKey); if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'document revision'); const revision = Number(old && old.revision || 0) + 1; shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op)); revisions[change.logicalKey] = revision; } return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; } - async readEntity(store, recordId, options = {}) { const row = shared.entities.get(store).get(String(recordId)) || null; return options.withMeta ? clone(row) : row && clone(row.data); } + async mutate(changes, options = {}) { const op = String(options.operationId || `doc-${++shared.counter}`); const revisions = {}; for (const change of changes) { const old = shared.docs.get(change.logicalKey); if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'document revision'); const revision = Number(old && old.revision || 0) + 1; shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op)); revisions[change.logicalKey] = revision; } return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; } + async readEntity(store, recordId) { const row = shared.entities.get(store).get(String(recordId)); return row ? clone(row.data) : null; } async listEntities(store) { if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); return Array.from(shared.entities.get(store).values()).map((row) => clone(row.data)); } - async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); shared.entityMutationCount += 1; for (const item of operations) { const rows = shared.entities.get(item.store); const old = rows.get(String(item.recordId)); if (item.expectedRevision !== undefined && Number(item.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'entity revision'); if (item.type === 'delete') { rows.delete(String(item.recordId)); continue; } const data = clone(item.data); rows.set(String(item.recordId), { recordId: String(item.recordId), revision: Number(old && old.revision || 0) + 1, operationId: op, updatedAt: new Date().toISOString(), data, checksum: checksum(data) }); } return { committed: true, operationId: op, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; } + async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); for (const item of operations) { const rows = shared.entities.get(item.store); const old = rows.get(String(item.recordId)); rows.set(String(item.recordId), { recordId: String(item.recordId), revision: Number(old && old.revision || 0) + 1, operationId: op, data: clone(item.data) }); } return { committed: true, operationId: op, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; } status() { return { state: this.state, backend: this.backend, failure: null }; } } - const internals = { DataKernel: Kernel, AppDataError, catalog, clone, checksum, parseLegacyValue, randomId: (prefix) => `${prefix}-${++shared.counter}`, nowIso: () => new Date().toISOString(), makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), validateEnvelope: (entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)), readLegacyValues: async () => { shared.legacyReadCount += 1; return clone(legacyValues); }, readLegacyExternalBackup: async () => { shared.externalReadCount += 1; return clone(options.externalBackup || null); } }; + const internals = { DataKernel: Kernel, AppDataError, catalog, clone, checksum, randomId: (prefix) => `${prefix}-${++shared.counter}`, nowIso: () => new Date().toISOString(), makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), validateEnvelope: (entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)), readLegacyValues: async () => { shared.legacyReads += 1; return clone(legacyValues); }, readLegacyExternalBackup: async () => { shared.externalReads += 1; return clone(options.externalBackup || null); } }; const sandbox = { console: { log() {}, warn() {}, error() {} }, Date, JSON, Math, Map, Set, Promise, structuredClone, __AppDataV2Internals: internals, sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} } }; sandbox.window = sandbox; sandbox.globalThis = sandbox; const context = vm.createContext(sandbox); vm.runInContext(recordSource, context, { filename: 'practiceRecordSource.js' }); vm.runInContext(appDataSource, context, { filename: 'appData.js' }); return { app: sandbox.AppData, shared }; } @@ -87,253 +61,51 @@ async function run() { const empty = harness({}); assert.deepStrictEqual(await empty.app.practice.list({ projection: 'light' }), [], 'empty legacy migrates to empty list'); - const externalMerge = harness({ practice_records: [ - { id: 'idb-only', type: 'reading', title: 'IndexedDB only', totalQuestions: 1, correctAnswers: 1 }, - { id: 'shared-source', type: 'reading', title: 'IndexedDB wins', totalQuestions: 1, correctAnswers: 1 } - ] }, {}, { externalBackup: { practiceRecords: [ + const merged = harness({ practice_records: [ + { id: 'idb-only', type: 'reading', title: 'IDB only', totalQuestions: 1, correctAnswers: 1 }, + { id: 'shared', type: 'reading', title: 'IDB wins', totalQuestions: 1, correctAnswers: 1 } + ] }, { externalBackup: { practiceRecords: [ { id: 'external-only', type: 'reading', title: 'External only', totalQuestions: 1, correctAnswers: 1 }, - { id: 'shared-source', type: 'reading', title: 'External loses', totalQuestions: 1, correctAnswers: 0 } + { id: 'shared', type: 'reading', title: 'External loses', totalQuestions: 1, correctAnswers: 0 } ] } }); - await externalMerge.app.ready; - assert.deepStrictEqual( - (await externalMerge.app.practice.list({ projection: 'light' })).map((record) => record.id).sort(), - ['external-only', 'idb-only', 'shared-source'], - 'external JSON and v1 IndexedDB records must be unioned' - ); - assert.strictEqual((await externalMerge.app.practice.get('shared-source')).title, 'IndexedDB wins', - 'v1 IndexedDB must win same-id conflicts'); - assert.strictEqual(externalMerge.shared.docs.get('system.migrations').data.externalBackupV1.status, 'consumed'); - const secondExternalBoot = harness({ practice_records: [] }, {}, { - shared: externalMerge.shared, - externalBackup: { practice_records: [{ id: 'must-not-reimport', type: 'reading', totalQuestions: 1, correctAnswers: 1 }] } - }); - await secondExternalBoot.app.ready; - assert.strictEqual(externalMerge.shared.externalReadCount, 1, 'the frozen v1 JSON must be consumed only once'); - assert.strictEqual(await secondExternalBoot.app.practice.get('must-not-reimport'), null); - - const defaultLibrary = harness({ - active_exam_index_key: 'exam_index', - exam_index_configurations: [{ id: 'exam_index', key: 'exam_index', name: '默认题库' }], - exam_index: [{ id: 'must-not-migrate', type: 'reading' }] + const mergedSummaries = await merged.app.practice.list({ projection: 'light' }); + assert.deepStrictEqual(mergedSummaries.map((record) => record.id).sort(), ['external-only', 'idb-only', 'shared']); + assert.strictEqual(mergedSummaries.find((record) => record.id === 'shared').title, 'IDB wins'); + assert.strictEqual(merged.shared.docs.get('system.migrations').data.externalBackupV1.status, 'consumed'); + + const secondBoot = harness({ practice_records: [ + { id: 'must-not-resurrect', type: 'reading', totalQuestions: 1, correctAnswers: 1 } + ] }, { shared: merged.shared, externalBackup: { practiceRecords: [ + { id: 'must-not-reimport', type: 'reading', totalQuestions: 1, correctAnswers: 1 } + ] } }); + await secondBoot.app.ready; + assert.strictEqual(merged.shared.legacyReads, 1, 'completed migration must not rescan v1'); + assert.strictEqual(merged.shared.externalReads, 1, 'consumed external JSON must not be read again'); + assert.strictEqual((await secondBoot.app.practice.list({ projection: 'light' })).length, 3); + + const partialShared = { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), counter: 0, legacyReads: 0, externalReads: 0 }; + partialShared.entities.get('practiceSummaries').set('partial', { + recordId: 'partial', revision: 1, operationId: 'seed', data: { id: 'partial', type: 'reading', title: 'Keep v2 summary' } }); - await defaultLibrary.app.ready; - assert.strictEqual(await defaultLibrary.app.library.getActive(), null, 'v1 default sentinel must become the v2 built-in manifest selection'); - assert.deepStrictEqual(await defaultLibrary.app.library.listConfigurations(), [], 'the generated default index is not user library data'); + const partial = harness({ practice_records: [{ + id: 'partial', type: 'reading', title: 'Legacy summary', totalQuestions: 1, correctAnswers: 1, + answers: { 1: 'A' }, notes: { 1: 'Recovered note' } + }] }, { shared: partialShared }); + await partial.app.ready; + assert.strictEqual(partial.shared.entities.get('practiceSummaries').get('partial').data.title, 'Keep v2 summary'); + assert.strictEqual(partial.shared.entities.get('practiceDetails').get('partial').data.answers[1], 'A'); + assert.strictEqual(partial.shared.entities.get('practiceAnnotations').get('partial').data.notes[1], 'Recovered note'); const customLibrary = harness({ active_exam_index_key: 'exam_index_1700000000000', - exam_index_configurations: [{ id: 'exam_index_1700000000000', key: 'exam_index_1700000000000', name: '旧自定义题库' }], - exam_index_1700000000000: [{ id: 'legacy-custom-exam', type: 'reading' }] + exam_index_configurations: [{ id: 'exam_index_1700000000000', name: 'Legacy custom' }], + exam_index_1700000000000: [{ id: 'legacy-exam', type: 'reading' }] }); await customLibrary.app.ready; - const migratedCustomId = await customLibrary.app.library.getActive(); - assert.match(migratedCustomId, /^legacy-library-/, 'v1 custom library ids must be remapped out of the reserved namespace'); - assert.strictEqual((await customLibrary.app.library.getIndex(migratedCustomId))[0].id, 'legacy-custom-exam'); - assert.strictEqual(customLibrary.shared.docs.get('system.migrations').data.v1ToV2.status, 'complete', 'successful repair must persist a completion marker'); - - // Exact bad-migration wrappers are replaced from the live v1 source while - // fields written only on the wrapper after migration are preserved. - const poisoned = harness({ - settings: { theme: 'dark', notifications: false }, - vocab_words: [{ id: 'restored-vocab-word', term: 'recover' }], - practice_records: [{ id: 'recovered-from-poison', type: 'reading', totalQuestions: 1, correctAnswers: 1 }], - active_exam_index_key: 'exam_index_1800000000000', - exam_index_configurations: [{ id: 'exam_index_1800000000000', key: 'exam_index_1800000000000', name: '可恢复题库' }], - exam_index_1800000000000: [{ id: 'recovered-exam', type: 'reading' }] - }, { - 'settings.values': { - key: 'exam_system_settings', - value: JSON.stringify({ data: { theme: 'light', notifications: true }, version: '1.0.0', compressed: false }), - timestamp: 1, - currentOnly: true - }, - 'vocab.words': [], - 'library.configurations': [], - 'library.activeConfigurationId': '[object Object]' - }, { - seedDocumentOperationIds: { - 'settings.values': 'achievement-delivery-after-bad-migration', - 'vocab.words': 'legacy-documents-fnv1a-bad-row' - } - }); - await poisoned.app.ready; - assert.strictEqual((await poisoned.app.practice.get('recovered-from-poison')).correctAnswers, 1); - assert.strictEqual((await poisoned.app.settings.getAll()).theme, 'dark'); - assert.strictEqual((await poisoned.app.settings.getAll()).notifications, false); - assert.strictEqual((await poisoned.app.settings.getAll()).currentOnly, true); - assert.strictEqual((await poisoned.app.vocab.listWords())[0].id, 'restored-vocab-word'); - const repairedActive = await poisoned.app.library.getActive(); - assert.match(repairedActive, /^legacy-library-/); - assert.strictEqual((await poisoned.app.library.getIndex(repairedActive))[0].id, 'recovered-exam'); - assert.strictEqual( - poisoned.shared.docs.get('system.migrations').data.v1ToV2.mode, - 'persistent-reconcile' - ); - - // A completion marker and healthy v2 data never suppress the persistent - // legacy union. Healthy v2-only documents and records remain intact. - const healthyExisting = harness({ - settings: { theme: 'light', legacyOnly: true }, - vocab_words: [ - { id: 'shared-word', term: 'legacy-value' }, - { id: 'legacy-word', term: 'legacy-only' } - ], - practice_records: [{ id: 'intentionally-deleted', type: 'reading', totalQuestions: 1, correctAnswers: 1 }], - active_exam_index_key: 'exam_index_1900000000000', - exam_index_configurations: [{ id: 'exam_index_1900000000000', key: 'exam_index_1900000000000', name: '已删除题库' }], - exam_index_1900000000000: [{ id: 'deleted-exam', type: 'reading' }] - }, { - 'settings.values': { theme: 'dark' }, - 'vocab.words': [ - { id: 'shared-word', term: 'current-value' }, - { id: 'v2-word', term: 'v2-only' } - ], - 'system.migrations': { v1ToV2: { version: 3, status: 'complete', mode: 'existing-v2' } } - }, { - seedEntities: { - practiceSummaries: { 'v2-only': { id: 'v2-only', sessionId: 'v2-only', type: 'reading', totalQuestions: 1, correctAnswers: 1 } }, - practiceDetails: { 'v2-only': { recordId: 'v2-only', answers: { q1: 'V2' } } }, - practiceAnnotations: { 'v2-only': { recordId: 'v2-only', notes: { q1: 'V2' } } } - } - }); - await healthyExisting.app.ready; - assert.strictEqual((await healthyExisting.app.practice.get('intentionally-deleted')).correctAnswers, 1); - assert.strictEqual((await healthyExisting.app.practice.get('v2-only')).answers.q1, 'V2'); - assert.strictEqual((await healthyExisting.app.settings.getAll()).theme, 'dark'); - assert.strictEqual((await healthyExisting.app.settings.getAll()).legacyOnly, true); - const reconciledWords = await healthyExisting.app.vocab.listWords(); - assert.deepStrictEqual(reconciledWords.map((word) => word.id).sort(), ['legacy-word', 'shared-word', 'v2-word']); - assert.strictEqual(reconciledWords.find((word) => word.id === 'shared-word').term, 'current-value'); - const restoredHealthyLibraryId = (await healthyExisting.app.library.listConfigurations())[0].id; - assert.match(restoredHealthyLibraryId, /^legacy-library-/); - assert.strictEqual((await healthyExisting.app.library.getIndex(restoredHealthyLibraryId))[0].id, 'deleted-exam'); - assert.strictEqual(healthyExisting.shared.docs.get('system.migrations').data.v1ToV2.mode, 'persistent-reconcile'); - - // A poisoned active pointer is repaired from v1 while the healthy v2-only - // library remains in the union. - const activePoisonWithHealthyLibrary = harness({ - active_exam_index_key: 'exam_index_2000000000000', - exam_index_configurations: [{ id: 'exam_index_2000000000000', key: 'exam_index_2000000000000', name: '旧库' }], - exam_index_2000000000000: [{ id: 'must-stay-deleted', type: 'reading' }] - }, { - 'library.configurations': [{ id: 'current-library', key: 'current-library', name: '当前库', examCount: 1 }], - 'library.importedIndexes': { 'current-library': [{ id: 'current-exam', type: 'reading' }] }, - 'library.activeConfigurationId': '[object Object]' - }); - await activePoisonWithHealthyLibrary.app.ready; - const reconciledLibraryIds = (await activePoisonWithHealthyLibrary.app.library.listConfigurations()).map((item) => item.id).sort(); - assert.strictEqual(reconciledLibraryIds.length, 2); - assert(reconciledLibraryIds.includes('current-library')); - const restoredActiveId = reconciledLibraryIds.find((id) => id !== 'current-library'); - assert.match(restoredActiveId, /^legacy-library-/); - assert.strictEqual(await activePoisonWithHealthyLibrary.app.library.getActive(), restoredActiveId); - assert.deepStrictEqual(Object.keys(activePoisonWithHealthyLibrary.shared.docs.get('library.importedIndexes').data).sort(), reconciledLibraryIds); - assert.strictEqual( - (await activePoisonWithHealthyLibrary.app.library.getIndex('current-library'))[0].id, - 'current-exam' - ); - assert.strictEqual( - (await activePoisonWithHealthyLibrary.app.library.getIndex(restoredActiveId))[0].id, - 'must-stay-deleted' - ); - - // Persistent reconciliation adds v1-only IDs, preserves complete healthy - // v2 records on collision, and atomically replaces a partial three-layer row. - const mixedLegacy = { - practice_records: [ - { id: 'shared-id', type: 'reading', title: 'V1 Shared', totalQuestions: 1, correctAnswers: 1, answers: { q1: 'V1' }, notes: { q1: 'V1' } }, - { id: 'v1-only', type: 'reading', title: 'V1 Only', totalQuestions: 1, correctAnswers: 1 }, - { id: 'partial-id', type: 'reading', title: 'V1 Repaired', totalQuestions: 1, correctAnswers: 1, answers: { q1: 'V1' }, notes: { q1: 'V1' } } - ] - }; - const mixed = harness(mixedLegacy, { - 'system.migrations': { v1ToV2: { version: 3, status: 'complete' } } - }, { - seedEntities: { - practiceSummaries: { - 'shared-id': { id: 'shared-id', sessionId: 'shared-id', title: 'V2 Shared', type: 'reading', totalQuestions: 1, correctAnswers: 0 }, - 'v2-only': { id: 'v2-only', sessionId: 'v2-only', title: 'V2 Only', type: 'reading', totalQuestions: 1, correctAnswers: 1 }, - 'partial-id': { id: 'partial-id', sessionId: 'partial-id', title: 'Broken Partial', type: 'reading', totalQuestions: 1, correctAnswers: 0 } - }, - practiceDetails: { - 'shared-id': { recordId: 'shared-id', answers: { q1: 'V2' } }, - 'v2-only': { recordId: 'v2-only', answers: { q1: 'V2' } } - }, - practiceAnnotations: { - 'shared-id': { recordId: 'shared-id', notes: { q1: 'V2' } }, - 'v2-only': { recordId: 'v2-only', notes: { q1: 'V2' } } - } - } - }); - await mixed.app.ready; - assert.deepStrictEqual( - (await mixed.app.practice.list({ projection: 'light' })).map((item) => item.id).sort(), - ['partial-id', 'shared-id', 'v1-only', 'v2-only'] - ); - assert.strictEqual((await mixed.app.practice.get('shared-id')).answers.q1, 'V2'); - assert.strictEqual((await mixed.app.practice.get('shared-id')).title, 'V2 Shared'); - assert.strictEqual((await mixed.app.practice.get('partial-id')).answers.q1, 'V1'); - assert.strictEqual((await mixed.app.practice.get('partial-id')).title, 'V1 Repaired'); - - const revisionSnapshot = Object.fromEntries(Array.from(mixed.shared.entities.entries()).map(([store, rows]) => [ - store, - Object.fromEntries(Array.from(rows.entries()).map(([id, row]) => [id, row.revision])) - ])); - const mutationSnapshot = { - documents: mixed.shared.documentMutationCount, - entities: mixed.shared.entityMutationCount - }; - const rebooted = harness(mixedLegacy, {}, { shared: mixed.shared }); - await rebooted.app.ready; - assert.strictEqual(rebooted.shared.legacyReadCount, 2, 'every startup must read v1 even after a complete marker'); - assert.strictEqual(rebooted.shared.documentMutationCount, mutationSnapshot.documents, 'idempotent reboot must not rewrite documents'); - assert.strictEqual(rebooted.shared.entityMutationCount, mutationSnapshot.entities, 'idempotent reboot must not rewrite practice layers'); - assert.deepStrictEqual( - Object.fromEntries(Array.from(rebooted.shared.entities.entries()).map(([store, rows]) => [ - store, - Object.fromEntries(Array.from(rows.entries()).map(([id, row]) => [id, row.revision])) - ])), - revisionSnapshot - ); - await rebooted.app.practice.delete('v1-only'); - assert.strictEqual(await rebooted.app.practice.get('v1-only'), null); - const mutationsAfterDelete = rebooted.shared.entityMutationCount; - const restoredAfterDelete = harness(mixedLegacy, {}, { shared: rebooted.shared }); - await restoredAfterDelete.app.ready; - assert.strictEqual((await restoredAfterDelete.app.practice.get('v1-only')).id, 'v1-only'); - assert.strictEqual( - restoredAfterDelete.shared.entityMutationCount, - mutationsAfterDelete + 1, - 'a v1-backed deletion is restored by exactly one atomic reconciliation' - ); - - const transientLegacy = { - active_sessions: [{ - id: 'legacy-active-session', - sessionId: 'legacy-active-session', - timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() - }, { - id: 'legacy-stale-session', - sessionId: 'legacy-stale-session', - timestamp: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString() - }] - }; - const transientFirstBoot = harness(transientLegacy); - await transientFirstBoot.app.ready; - assert.deepStrictEqual( - (await transientFirstBoot.app.recovery.listActiveSessions()).map((entry) => entry.id), - ['legacy-active-session'], - 'startup keeps fresh recovery rows while pruning entries beyond the 30-day TTL' - ); - await transientFirstBoot.app.recovery.completeActiveSession('legacy-active-session'); - assert.strictEqual((await transientFirstBoot.app.recovery.listActiveSessions()).length, 0); - const transientSecondBoot = harness(transientLegacy, {}, { shared: transientFirstBoot.shared }); - await transientSecondBoot.app.ready; - assert.strictEqual( - (await transientSecondBoot.app.recovery.listActiveSessions()).length, - 0, - 'completed transient recovery rows must not be resurrected from frozen v1 data' - ); + const activeId = await customLibrary.app.library.getActive(); + assert.match(activeId, /^legacy-library-/); + assert.strictEqual((await customLibrary.app.library.getIndex(activeId))[0].id, 'legacy-exam'); + assert.strictEqual((await customLibrary.app.library.listConfigurations())[0].id, activeId); console.log('PASS legacyMigrationBrickRegression'); } diff --git a/developer/tests/js/practiceLightProjectionRenderContract.test.js b/developer/tests/js/practiceLightProjectionRenderContract.test.js index 210c4abf..1b16a3cd 100644 --- a/developer/tests/js/practiceLightProjectionRenderContract.test.js +++ b/developer/tests/js/practiceLightProjectionRenderContract.test.js @@ -159,6 +159,13 @@ function loadRealAppData() { const rows = Array.from(shared.entities.get(store).values()); return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); } + async readPracticeSnapshot(recordIds = null, options = {}) { + const ids = recordIds == null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); + const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + return Object.fromEntries(stores.map((store) => [store, Array.from(shared.entities.get(store).values()) + .filter((row) => !ids || ids.has(String(row.recordId))) + .map((row) => options.withMeta ? clone(row) : clone(row.data))])); + } async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); const revisions = {}; diff --git a/developer/tests/js/siteDataReset.test.js b/developer/tests/js/siteDataReset.test.js index 1087ef26..0c028d44 100644 --- a/developer/tests/js/siteDataReset.test.js +++ b/developer/tests/js/siteDataReset.test.js @@ -5,25 +5,14 @@ import path from 'node:path'; import vm from 'node:vm'; import { fileURLToPath } from 'node:url'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const resetSource = fs.readFileSync(path.join(repoRoot, 'js/core/siteDataReset.js'), 'utf8'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const source = fs.readFileSync(path.join(root, 'js/core/siteDataReset.js'), 'utf8'); -function createStorage(seed, behavior = {}) { - const values = new Map(Object.entries(seed || {})); +function createStorage(seed = {}) { + const values = new Map(Object.entries(seed)); return { values, clearCalls: 0, - getItem(key) { - return values.has(key) ? values.get(key) : null; - }, - setItem(key, value) { - if (behavior.failSetItem) throw new Error('storage write failed'); - values.set(key, String(value)); - }, - removeItem(key) { - values.delete(key); - }, clear() { this.clearCalls += 1; values.clear(); @@ -31,222 +20,100 @@ function createStorage(seed, behavior = {}) { }; } -/** - * Deterministic timer host. The blocked-deletion timeout is measured in seconds, - * so tests drive it explicitly instead of sleeping. - */ -function createClock() { - let now = 0; - let sequence = 0; - const timers = new Map(); - return { - timers, - setTimeout(callback, delay) { - sequence += 1; - timers.set(sequence, { callback, at: now + (Number(delay) || 0), delay: Number(delay) || 0 }); - return sequence; - }, - clearTimeout(id) { - timers.delete(id); - }, - pendingDelays() { - return Array.from(timers.values()).map((timer) => timer.delay); - }, - advance(ms) { - now += ms; - const due = Array.from(timers.entries()) - .filter(([, timer]) => timer.at <= now) - .sort((a, b) => a[1].at - b[1].at); - for (const [id, timer] of due) { - timers.delete(id); - timer.callback(); - } - return due.length; - } - }; -} - -/** Let queued microtasks/immediates drain so IndexedDB stub events can fire. */ -async function flushAsync(rounds = 12) { - for (let i = 0; i < rounds; i += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } -} - -/** - * Fail loudly instead of hanging the suite if a reset never settles. - * - * The guard timer is deliberately not unref'd: a regression that leaves the - * reset pending would otherwise let Node drain its loop and exit 0 without ever - * printing a failure. - */ -function withDeadline(promise, label, ms = 5000) { - let timer; - const guard = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms); - }); - return Promise.race([promise, guard]).finally(() => clearTimeout(timer)); -} - function createHarness(options = {}) { const events = []; const messages = []; - const clock = createClock(); - const localStorage = createStorage(Object.assign({ - hasSeenGplLicense: 'true', - 'ielts_atlas:v2:authoritative:preferences.values': '{"consent":{"hasSeenGplLicense":true}}' - }, options.seedLocalStorage || {}), { failSetItem: options.failLocalStorageWrites }); - const sessionStorage = createStorage({ - 'ielts_atlas:v2:session:recovery.windowSession': '{"active":true}' - }, { failSetItem: options.failSessionStorageWrites }); - const deleteModes = options.deleteModes || {}; - const pendingRequests = []; - /** - * Rows a "database" holds. `deleteDatabase` only empties the bucket when the - * browser actually runs the request, so a late deletion firing against data - * written after the reset is directly observable. - */ - const databaseContents = new Map( - Object.entries(options.databaseContents || { IELTSAtlasDataV2: ['seed-record'] }) - .map(([name, rows]) => [name, rows.slice()]) - ); + const requests = []; + const deleteModes = Object.assign({}, options.deleteModes); + const localStorage = createStorage({ consent: 'yes' }); + const sessionStorage = createStorage({ recovery: 'active' }); const indexedDB = { - databaseContents, deleteDatabase(name) { - events.push('delete:' + name); - const request = {}; - const entry = { name, request, completed: false }; - pendingRequests.push(entry); - /** Run what the browser does when the request finally reaches the front. */ - entry.completeDeletion = () => { - if (entry.completed) return false; - entry.completed = true; - databaseContents.set(name, []); - events.push('deleted:' + name); - if (request.onsuccess) request.onsuccess({ target: request }); - return true; + events.push(`delete:${name}`); + const request = { name, completed: false }; + requests.push(request); + request.complete = () => { + if (request.completed) return; + request.completed = true; + events.push(`deleted:${name}`); + request.onsuccess?.({ target: request }); }; queueMicrotask(() => { const mode = deleteModes[name] || 'success'; if (mode === 'error') { - request.error = new Error('delete failed: ' + name); - if (request.onerror) request.onerror({ target: request }); - return; - } - if (mode === 'blocked-forever' || mode === 'blocked-then-late-success') { - // Another tab keeps the connection open: onblocked fires and no - // terminal event ever follows while that tab stays open. The - // request stays armed — tests fire `completeDeletion()` to - // replay the moment that tab closes. - if (request.onblocked) request.onblocked({ target: request }); + request.error = new Error(`delete failed: ${name}`); + request.onerror?.({ target: request }); return; } - if (mode === 'blocked-success' && request.onblocked) { - request.onblocked({ target: request }); + if (mode === 'blocked' || mode === 'blocked-success') { + request.onblocked?.({ target: request }); + if (mode === 'blocked') return; } - queueMicrotask(() => { entry.completeDeletion(); }); + queueMicrotask(request.complete); }); return request; } }; const externalBackup = { - prepareCalls: 0, - unbindCalls: 0, + calls: 0, async prepareForFullReset() { - this.prepareCalls += 1; + this.calls += 1; events.push('external:prepare'); - if (options.prepareError) throw new Error('prepare failed'); - if (options.preparePending) return new Promise(() => {}); - return { success: true, diskFilesPreserved: true }; - }, - async unbindDirectory() { - this.unbindCalls += 1; - events.push('external:unbind'); - if (options.unbindPending) return new Promise(() => {}); - return { success: true, diskFilesPreserved: true }; + if (options.externalError) throw new Error('external backup busy'); } }; - if (options.useUnbind) delete externalBackup.prepareForFullReset; const windowStub = { - name: options.windowName || '', - console: Object.assign({}, console, { error() {} }), indexedDB, localStorage, sessionStorage, ExternalBackupService: externalBackup, - setTimeout: (callback, delay) => clock.setTimeout(callback, delay), - clearTimeout: (id) => clock.clearTimeout(id), confirm: () => options.confirmed !== false, + showMessage(message, type) { messages.push({ message, type }); }, + console: Object.assign({}, console, { error() {} }), location: { reloadCalls: 0, - reload() { - this.reloadCalls += 1; - events.push('reload'); - } + reload() { this.reloadCalls += 1; events.push('reload'); } } }; - function installMessageCenter() { - windowStub.showMessage = function showMessage(message, type) { - messages.push({ message, type }); - }; - } - // index.html loads core-foundation (this module) before the ui-shell bundle - // that defines showMessage. `deferMessageCenter` reproduces that ordering so - // boot-time notices are tested against the real world, not a friendlier one. - if (!options.deferMessageCenter) installMessageCenter(); const context = vm.createContext({ window: windowStub, globalThis: windowStub, console: windowStub.console, Promise, Object, - Error, - Math + Error }); - vm.runInContext(resetSource, context, { filename: 'js/core/siteDataReset.js' }); + vm.runInContext(source, context, { filename: 'siteDataReset.js' }); return { windowStub, events, messages, + requests, + deleteModes, localStorage, sessionStorage, externalBackup, - clock, - deleteModes, - pendingRequests, - databaseContents, - /** Bring up the UI layer that owns showMessage, as ui-shell.bundle.js does. */ - installMessageCenter, - /** Replay the browser finally running an abandoned deleteDatabase request. */ - completeLateDeletion(name) { - const entry = pendingRequests.filter((item) => item.name === name && !item.completed).pop(); - assert.ok(entry, `expected an outstanding deleteDatabase request for ${name}`); - return entry.completeDeletion(); - }, - /** Simulate the app reopening a database and writing fresh user data. */ - writeFreshData(name, row) { - const rows = databaseContents.get(name) || []; - rows.push(row); - databaseContents.set(name, rows); - return rows; + complete(name) { + const request = requests.find((item) => item.name === name && !item.completed); + assert.ok(request, `missing pending request for ${name}`); + request.complete(); } }; } -async function testCancelledResetHasNoSideEffects() { +async function flush() { + for (let index = 0; index < 6; index += 1) await Promise.resolve(); +} + +async function testCancelledReset() { const harness = createHarness({ confirmed: false }); - assert.equal(typeof harness.windowStub.clearCache, 'function', 'fresh core load must expose clearCache'); const result = await harness.windowStub.clearCache(); - assert.equal(result.success, false); assert.equal(result.reason, 'cancelled'); assert.deepEqual(harness.events, []); - assert.equal(harness.externalBackup.prepareCalls, 0); assert.equal(harness.localStorage.clearCalls, 0); - assert.equal(harness.sessionStorage.clearCalls, 0); - assert.equal(harness.windowStub.location.reloadCalls, 0); } -async function testSuccessfulResetReturnsToFreshBrowserState() { +async function testSuccessfulReset() { const harness = createHarness(); const result = await harness.windowStub.clearCache(); assert.equal(result.success, true); @@ -255,608 +122,75 @@ async function testSuccessfulResetReturnsToFreshBrowserState() { 'ExamSystemDB', 'IELTSAtlasExternalBackupV2' ]); - assert.equal(harness.events[0], 'external:prepare', 'external disk writer must stop before database deletion'); - assert.deepEqual(harness.events.slice(1, 4).sort(), [ - 'delete:ExamSystemDB', - 'delete:IELTSAtlasDataV2', - 'delete:IELTSAtlasExternalBackupV2' - ]); - assert.equal(harness.localStorage.values.size, 0, 'GPL consent and v2 fallback data must be removed'); - assert.equal(harness.sessionStorage.values.size, 0, 'window recovery state must be removed'); + assert.equal(result.databases.includes('ExamSystemExternalBackup'), false, + 'legacy external handle database stays untouched for this release'); + assert.equal(harness.events[0], 'external:prepare'); + assert.equal(harness.localStorage.values.size, 0); + assert.equal(harness.sessionStorage.values.size, 0); assert.equal(harness.windowStub.location.reloadCalls, 1); assert.equal(result.externalBackupFilesPreserved, true); } -async function testBlockedDeletionWaitsAndWarns() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-success' } - }); - const result = await harness.windowStub.SiteDataReset.perform({ reload: false }); +async function testBlockedDeletionKeepsWaiting() { + const harness = createHarness({ deleteModes: { IELTSAtlasDataV2: 'blocked' } }); + const pending = harness.windowStub.SiteDataReset.perform({ reload: false }); + let settled = false; + pending.finally(() => { settled = true; }); + await flush(); + assert.equal(settled, false); + assert.equal(harness.localStorage.clearCalls, 0, 'storage clears only after every database is deleted'); + assert.ok(harness.messages.some((entry) => entry.type === 'warning' && /关闭其他标签页/.test(entry.message))); + harness.complete('IELTSAtlasDataV2'); + const result = await pending; assert.equal(result.success, true); - assert.ok(harness.messages.some((entry) => entry.type === 'warning' && /其他 IELTS Atlas 标签页/.test(entry.message))); assert.equal(harness.localStorage.values.size, 0); } -async function testDeletionFailureDoesNotClaimSuccessOrReload() { - const harness = createHarness({ - deleteModes: { ExamSystemDB: 'error' } - }); +async function testDeletionFailureIsVisible() { + const harness = createHarness({ deleteModes: { ExamSystemDB: 'error' } }); const result = await harness.windowStub.clearCache(); assert.equal(result.success, false); assert.equal(result.reason, 'partial_reset'); - assert.equal(result.terminal, true); - assert.equal(harness.localStorage.clearCalls, 1, 'terminal cleanup must continue after one database deletion error'); - assert.equal(harness.sessionStorage.clearCalls, 1); - assert.equal(harness.windowStub.location.reloadCalls, 1, - 'a page whose data kernel may have been deleted must not remain interactive'); + assert.equal(result.terminal, false); + assert.equal(harness.windowStub.location.reloadCalls, 0); + assert.equal(harness.localStorage.clearCalls, 1); assert.ok(harness.messages.some((entry) => entry.type === 'error')); } -async function testExternalQuiesceFailureCannotBlockRecoveryReset() { - const harness = createHarness({ prepareError: true }); +async function testExternalFailureStopsBeforeDeletion() { + const harness = createHarness({ externalError: true }); const result = await harness.windowStub.clearCache(); - assert.equal(result.success, false); - assert.equal(result.reason, 'partial_reset'); - assert.equal(result.terminal, true); - assert.equal(harness.localStorage.values.size, 0); - assert.equal(harness.sessionStorage.values.size, 0); - assert.equal(harness.windowStub.location.reloadCalls, 1); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3, - 'reset must still delete all browser databases when AppData/external backup initialization is broken'); -} - -async function testPendingExternalQuiesceTimesOutAndConcurrentCallsShareRecovery() { - for (const mode of ['prepare', 'unbind']) { - const harness = createHarness(mode === 'prepare' - ? { preparePending: true } - : { useUnbind: true, unbindPending: true }); - const first = harness.windowStub.SiteDataReset.perform({ reload: false }); - const second = harness.windowStub.SiteDataReset.perform({ reload: false }); - await flushAsync(); - - const delays = harness.clock.pendingDelays(); - assert.equal(delays.length, 1, `${mode} must arm exactly one quiesce timeout`); - assert.ok(delays[0] >= 5000 && delays[0] <= 10000); - assert.equal(harness.events.filter((entry) => entry === `external:${mode}`).length, 1, - `concurrent calls must share one ${mode} attempt`); - - harness.clock.advance(delays[0]); - const [firstResult, secondResult] = await withDeadline( - Promise.all([first, second]), - `${mode} quiesce timeout recovery` - ); - assert.equal(firstResult, secondResult, 'concurrent callers must receive the same outcome object'); - assert.equal(firstResult.success, false, 'a quiesce timeout must remain visible as partial reset'); - assert.equal(firstResult.terminal, false); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3, - 'quiesce timeout must not prevent database recovery cleanup'); - const timeout = Array.from(firstResult.errors).find((entry) => entry.stage === 'external-backup-quiesce'); - assert.equal(timeout.error.code, 'EXTERNAL_BACKUP_QUIESCE_TIMEOUT'); - assert.equal(harness.clock.timers.size, 0); - } -} - -async function testQuiesceTimeoutNonTerminalResetCanRunAgain() { - for (const mode of ['prepare', 'unbind']) { - const options = mode === 'prepare' - ? { preparePending: true } - : { useUnbind: true, unbindPending: true }; - const harness = createHarness(options); - const firstPending = harness.windowStub.SiteDataReset.perform({ reload: false }); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - const first = await withDeadline(firstPending, `${mode} timeout before retry`); - - assert.equal(first.success, false); - assert.equal(first.terminal, false); - assert.ok(first.errors.some((entry) => entry.stage === 'external-backup-quiesce')); - - if (mode === 'prepare') options.preparePending = false; - else options.unbindPending = false; - const second = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false }), - `${mode} retry after timeout` - ); - - assert.equal(second.success, true, 'a settled non-terminal timeout must release resetPromise'); - assert.notEqual(second, first, 'the retry must not replay the previous partial outcome'); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 6, - 'the retry must issue a fresh deletion for every database'); - assert.equal(mode === 'prepare' - ? harness.externalBackup.prepareCalls - : harness.externalBackup.unbindCalls, 2); - } -} - -async function testPermanentlyBlockedDeletionTimesOutInsteadOfHanging() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-forever' } - }); - const pending = harness.windowStub.SiteDataReset.perform({ reload: false }); - let settled = false; - pending.then(() => { settled = true; }, () => { settled = true; }); - - await flushAsync(); - assert.equal(settled, false, 'reset must still be waiting while the blocked timeout has not elapsed'); - const delays = harness.clock.pendingDelays(); - assert.equal(delays.length, 1, 'a blocked deletion must arm exactly one timeout'); - assert.ok(delays[0] >= 5000 && delays[0] <= 10000, - `blocked timeout must be 5-10s, got ${delays[0]}ms`); - - harness.clock.advance(delays[0]); - const result = await withDeadline(pending, 'permanently blocked reset'); - - assert.equal(result.success, false, 'a database that was never deleted must not report success'); - assert.equal(result.reason, 'partial_reset'); - assert.equal(result.blocked, true, 'blocked resets must be distinguishable from generic failures'); - assert.deepEqual(JSON.parse(JSON.stringify(result.blockedDatabases)), ['IELTSAtlasDataV2']); - assert.equal(result.retryable, true); - - const blockedErrors = Array.from(result.errors).filter((entry) => entry.stage === 'delete-database-blocked'); - assert.equal(blockedErrors.length, 1, 'blocked failures must be tagged with their own stage'); - assert.equal(blockedErrors[0].database, 'IELTSAtlasDataV2'); - assert.equal(blockedErrors[0].blocked, true); - assert.equal(blockedErrors[0].error.code, 'DELETE_DATABASE_BLOCKED'); - - assert.ok(harness.messages.some((entry) => entry.type === 'warning' && /其他 IELTS Atlas 标签页/.test(entry.message)), - 'user must be warned while the deletion is blocked'); - const finalError = harness.messages.filter((entry) => entry.type === 'error').pop(); - assert.ok(finalError, 'a blocked reset must end with an error-level message'); - assert.ok(/关闭/.test(finalError.message) && /重新点击|重试|再次/.test(finalError.message), - `blocked guidance must tell the user to close other tabs and retry, got: ${finalError.message}`); - assert.ok(/IELTSAtlasDataV2/.test(finalError.message), 'guidance must name the database that is still held'); -} - -async function testBlockedTimeoutStillCompletesTerminalCleanup() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-forever' } - }); - const markerKey = harness.windowStub.SiteDataReset.PENDING_DELETION_MARKER_KEY; - const pending = harness.windowStub.SiteDataReset.perform(); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - await withDeadline(pending, 'blocked reset terminal cleanup'); - - assert.equal(harness.localStorage.clearCalls, 1, - 'one undeletable database must not skip web storage cleanup'); - assert.equal(harness.sessionStorage.clearCalls, 1); - assert.deepEqual(Array.from(harness.sessionStorage.values.keys()), [markerKey]); - // The marker is redundantly written after both stores are cleared so one - // unavailable storage backend cannot erase the cross-refresh warning. - assert.deepEqual(Array.from(harness.localStorage.values.keys()), [markerKey], - 'only the pending-deletion marker may survive the storage wipe'); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3, - 'the other databases must still be deleted'); - assert.equal(harness.windowStub.location.reloadCalls, 0, - 'the realm owning the real pending observer must not reload itself away'); - assert.equal(harness.clock.timers.size, 0, 'the blocked timeout must not leak after settling'); -} - -async function testBlockedResetCanBeRetriedAfterOtherTabCloses() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-forever' } - }); - const first = harness.windowStub.SiteDataReset.perform({ reload: false }); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - const firstResult = await withDeadline(first, 'first blocked reset'); - assert.equal(firstResult.success, false); - - // The user closes the other tab: the browser drains the abandoned request, - // which is what actually clears the pending state. - harness.completeLateDeletion('IELTSAtlasDataV2'); - await flushAsync(); - assert.equal(harness.windowStub.SiteDataReset.isDeletionPending(), false, - 'a completed late deletion must retire the pending state'); - - // The retry must actually re-run the deletion instead of replaying the - // cached failure from the singleton promise. - harness.deleteModes.IELTSAtlasDataV2 = 'success'; - const deletesBeforeRetry = harness.events.filter((entry) => entry.startsWith('delete:')).length; - const retryResult = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false }), - 'retry after blocked reset' - ); - - assert.equal(retryResult.success, true, 'retry must succeed once the blocking tab is gone'); - assert.notEqual(retryResult, firstResult, 'a failed reset must not be cached in resetPromise'); - assert.equal( - harness.events.filter((entry) => entry.startsWith('delete:')).length, - deletesBeforeRetry + 3, - 'retry must issue fresh deleteDatabase requests' - ); -} - -async function testLateSuccessAfterTimeoutCannotResurrectTheResult() { - const harness = createHarness({ - deleteModes: { ExamSystemDB: 'blocked-then-late-success' } - }); - const pending = harness.windowStub.SiteDataReset.perform({ reload: false }); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - const result = await withDeadline(pending, 'blocked reset with late success'); - assert.equal(result.success, false); - - // deleteDatabase cannot be aborted, so the browser may still fire onsuccess - // later. The handlers that could have resolved the caller's promise must be - // gone; whatever remains may only observe, never re-settle. - const entry = harness.pendingRequests.find((item) => item.name === 'ExamSystemDB'); - assert.ok(entry, 'stub must have recorded the blocked request'); - - // Replay what the browser would do once the other tab finally closes. - harness.completeLateDeletion('ExamSystemDB'); - if (entry.request.onblocked) entry.request.onblocked({ target: entry.request }); - await flushAsync(); - assert.equal(result.success, false, 'a late success must not flip the reported outcome'); - assert.equal(result.terminal, false, 'a late success must not flip the reload verdict either'); - assert.equal(harness.clock.timers.size, 0, 'no timer may survive a settled deletion'); -} - -/** - * The dangerous shape of an un-cancellable delete: it lands *after* the app has - * reopened the database and written new user data, and wipes that data. - * - * The module cannot stop the browser from running the request, so the contract - * under test is that it refuses to hand the user a "clean slate" illusion while - * the request is still armed — the reset stays reported as pending, and a new - * reset is refused rather than queued behind it. - */ -async function testLateDeletionIsTrackedUntilTheBrowserRunsIt() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-forever' } - }); - const markerKey = harness.windowStub.SiteDataReset.PENDING_DELETION_MARKER_KEY; - const pending = harness.windowStub.SiteDataReset.perform({ reload: false }); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - const result = await withDeadline(pending, 'blocked reset before late deletion'); - - assert.equal(result.success, false); - assert.equal(result.deletionPending, true, - 'an abandoned deleteDatabase request is still armed and must be reported as pending'); - assert.deepEqual(JSON.parse(JSON.stringify(result.pendingDatabases)), ['IELTSAtlasDataV2']); - assert.equal(harness.windowStub.SiteDataReset.isDeletionPending(), true); - assert.deepEqual( - JSON.parse(JSON.stringify(harness.windowStub.SiteDataReset.pendingDeletions())), - ['IELTSAtlasDataV2'] - ); - assert.ok(harness.localStorage.values.has(markerKey), - 'the pending state must survive the reload that follows a reset'); - - // The user keeps working; the app writes a new record into the database that - // was never deleted. - harness.writeFreshData('IELTSAtlasDataV2', 'record-written-after-reset'); - - // The blocking tab finally closes and the browser drains the old request. - harness.completeLateDeletion('IELTSAtlasDataV2'); - await flushAsync(); - - assert.equal(harness.windowStub.SiteDataReset.isDeletionPending(), false, - 'the pending state must clear once the browser reports the deletion done'); - assert.deepEqual(JSON.parse(JSON.stringify(harness.windowStub.SiteDataReset.pendingDeletions())), []); - assert.equal(harness.localStorage.values.has(markerKey), false, - 'a retired deletion must not leave a stale cross-refresh marker behind'); - assert.equal(result.success, false, 'the already-returned outcome must not be rewritten'); - - // Documents the hazard this state exists for: the late delete really did - // take the freshly written row with it. Nothing in JS can prevent that, which - // is precisely why the user must be warned instead of shown a clean slate. - assert.deepEqual(harness.databaseContents.get('IELTSAtlasDataV2'), [], - 'the late deletion drops data written after the reset - hence the pending warning'); -} - -/** A second reset must not queue another un-cancellable delete behind the first. */ -async function testSecondResetIsRefusedWhileADeletionIsStillPending() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-forever' } - }); - const pending = harness.windowStub.SiteDataReset.perform({ reload: false }); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - await withDeadline(pending, 'blocked reset before duplicate attempt'); - - const deletesAfterFirst = harness.events.filter((entry) => entry.startsWith('delete:')).length; - const messagesBefore = harness.messages.length; - // Asserted before the second call: without it a regression would silently - // start a real reset that parks on the fake clock, turning a clear failure - // into a suite-wide timeout. - assert.equal(harness.windowStub.SiteDataReset.isDeletionPending(), true, - 'the abandoned request must be registered before a duplicate reset is attempted'); - const second = await withDeadline( - harness.windowStub.clearCache(), - 'duplicate reset while a deletion is pending' - ); - - assert.equal(second.success, false, 'a reset that was refused must not report success'); - assert.equal(second.reason, 'deletion_pending'); - assert.equal(second.deletionPending, true); - assert.equal(second.terminal, false, 'a refused reset never tore the page down'); - assert.deepEqual(JSON.parse(JSON.stringify(second.pendingDatabases)), ['IELTSAtlasDataV2']); - assert.equal( - harness.events.filter((entry) => entry.startsWith('delete:')).length, - deletesAfterFirst, - 'no second deleteDatabase request may be queued while one is still armed' - ); - assert.equal(harness.windowStub.location.reloadCalls, 0); - - const explanation = harness.messages.slice(messagesBefore); - assert.ok(explanation.length, 'a refused reset must tell the user why'); - assert.ok( - explanation.some((entry) => /等待|其他 IELTS Atlas 标签页/.test(entry.message)), - `refusal must explain the pending deletion, got: ${JSON.stringify(explanation)}` - ); - - // Once the browser drains the old request the button works again. - harness.completeLateDeletion('IELTSAtlasDataV2'); - await flushAsync(); - harness.deleteModes.IELTSAtlasDataV2 = 'success'; - const third = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false }), - 'reset after the pending deletion retired' - ); - assert.equal(third.success, true, 'the refusal must lift once the deletion actually completes'); - assert.equal( - harness.events.filter((entry) => entry.startsWith('delete:')).length, - deletesAfterFirst + 3, - 'the unblocked retry must issue fresh deleteDatabase requests' - ); -} - -async function testLiveDeletionNeverExpiresAndMarkerFailureStaysFailSafe() { - const harness = createHarness({ - deleteModes: { IELTSAtlasDataV2: 'blocked-forever' }, - failLocalStorageWrites: true, - failSessionStorageWrites: true - }); - const pending = harness.windowStub.SiteDataReset.perform(); - await flushAsync(); - harness.clock.advance(harness.clock.pendingDelays()[0]); - const first = await withDeadline(pending, 'blocked reset with marker write failure'); - - assert.equal(first.success, false); - assert.equal(first.terminal, false, 'marker failure must not reload away the in-memory observer'); - assert.equal(first.markerPersisted, true, - 'window.name must preserve unknown deletion evidence when both Web Storage writes fail'); - assert.equal(first.deletionState, 'pending'); - assert.equal(harness.windowStub.SiteDataReset.deletionState(), 'pending'); + assert.equal(result.reason, 'external_backup_busy'); + assert.equal(harness.events.some((entry) => entry.startsWith('delete:')), false); + assert.equal(harness.localStorage.clearCalls, 0); assert.equal(harness.windowStub.location.reloadCalls, 0); - assert.equal(harness.localStorage.values.size, 0); - assert.equal(harness.sessionStorage.values.size, 0); - assert.match(harness.windowStub.name, /IELTS_ATLAS_SITE_RESET/, - 'the same-tab reload fallback must carry a namespaced marker'); - - const reloaded = createHarness({ windowName: harness.windowStub.name }); - assert.equal(reloaded.windowStub.SiteDataReset.deletionState(), 'unknown', - 'a new realm cannot observe the old IDBRequest and must call persisted evidence unknown'); - const restricted = await reloaded.windowStub.SiteDataReset.perform({ reload: false }); - assert.equal(restricted.reason, 'recovery_confirmation_required'); - assert.equal(restricted.deletionState, 'unknown'); - assert.equal(reloaded.events.filter((entry) => entry.startsWith('delete:')).length, 0, - 'unknown evidence must not queue another delete without recovery confirmation'); - - const recovered = await reloaded.windowStub.SiteDataReset.perform({ - reload: false, - recoveryConfirmed: true - }); - assert.equal(recovered.success, true); - assert.equal(recovered.deletionState, 'retired', - 'a confirmed recovery is retired only after fresh delete requests complete'); - assert.equal(reloaded.windowStub.SiteDataReset.deletionState(), 'retired'); - assert.doesNotMatch(reloaded.windowStub.name, /IELTS_ATLAS_SITE_RESET/, - 'verified recovery must clear the fallback instead of creating an unbounded lock'); - - // Advance far beyond the removed 60-second lock. Only the request's actual - // terminal event is allowed to make this realm safe or queue another delete. - harness.clock.advance(10 * 60 * 1000); - const deletesBeforeRetry = harness.events.filter((entry) => entry.startsWith('delete:')).length; - const refused = await harness.windowStub.SiteDataReset.perform({ reload: false }); - assert.equal(refused.reason, 'deletion_pending'); - assert.equal(refused.deletionState, 'pending'); - assert.equal(harness.windowStub.SiteDataReset.isDeletionPending(), true); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, deletesBeforeRetry); - - harness.completeLateDeletion('IELTSAtlasDataV2'); - await flushAsync(); - assert.equal(harness.windowStub.SiteDataReset.isDeletionPending(), false, - 'only the late success event may retire a live deletion'); - assert.equal(harness.windowStub.SiteDataReset.deletionState(), 'retired'); -} - -/** - * A marker left by a previous page load cannot be observed directly. It must - * require explicit recovery confirmation before another delete is queued. - */ -async function testCrossRefreshMarkerRequiresConfirmedRecovery() { - const markerKey = 'ielts_atlas:v2:site-reset:pending-deletions'; - const harness = createHarness({ - deferMessageCenter: true, - seedLocalStorage: { - [markerKey]: JSON.stringify({ databases: ['IELTSAtlasDataV2'], at: Date.now() }) - } - }); - - // core-foundation loads before the bundle that defines showMessage, so the - // adoption warning must wait for the UI layer instead of being swallowed by - // the console.log fallback. - assert.equal(harness.messages.length, 0, 'the warning cannot be delivered before showMessage exists'); - harness.installMessageCenter(); - harness.clock.advance(250); - assert.ok( - harness.messages.some((entry) => entry.type === 'warning' && /等待/.test(entry.message)), - `a reloaded page must surface the still-armed deletion, got: ${JSON.stringify(harness.messages)}` - ); - assert.deepEqual( - JSON.parse(JSON.stringify(harness.windowStub.SiteDataReset.pendingDeletions())), - ['IELTSAtlasDataV2'], - 'the adopted marker must be visible to callers' - ); - - const restricted = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false }), - 'unconfirmed recovery after adopting a marker' - ); - assert.equal(restricted.success, false); - assert.equal(restricted.reason, 'recovery_confirmation_required'); - assert.equal(restricted.recoveryConfirmationRequired, true); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 0, - 'unconfirmed recovery must not grow the un-cancellable deletion queue'); - - const result = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false, recoveryConfirmed: true }), - 'confirmed recovery after adopting a marker' - ); - assert.equal(result.success, true, 'explicit confirmation must provide a bounded recovery path'); - assert.equal( - harness.events.filter((entry) => entry.startsWith('delete:')).length, 3, - 'the recovery reset must really run' - ); - assert.equal(harness.localStorage.values.has(markerKey), false, - 'a completed reset must clear the adopted marker'); - assert.deepEqual(JSON.parse(JSON.stringify(harness.windowStub.SiteDataReset.pendingDeletions())), [], - 'the recovery reset must retire the adopted name'); -} - -/** Expired or malformed evidence stays restricted but has a confirmed recovery path. */ -async function testStaleOrCorruptMarkerRequiresConfirmedRecovery() { - const markerKey = 'ielts_atlas:v2:site-reset:pending-deletions'; - const expired = createHarness({ - seedLocalStorage: { - [markerKey]: JSON.stringify({ databases: ['IELTSAtlasDataV2'], at: Date.now() - 3600000 }) - } - }); - assert.deepEqual(JSON.parse(JSON.stringify(expired.windowStub.SiteDataReset.pendingDeletions())), - ['IELTSAtlasDataV2'], 'expired evidence must not become an automatic false-safe state'); - const expiredRestricted = await expired.windowStub.SiteDataReset.perform({ reload: false }); - assert.equal(expiredRestricted.reason, 'recovery_confirmation_required'); - assert.equal(expiredRestricted.markerExpired, true); - const expiredRecovered = await expired.windowStub.SiteDataReset.perform({ - reload: false, - recoveryConfirmed: true - }); - assert.equal(expiredRecovered.success, true, 'confirmation must avoid a permanent stale-marker lock'); - - const corrupt = createHarness({ seedLocalStorage: { [markerKey]: 'not json at all' } }); - assert.deepEqual(JSON.parse(JSON.stringify(corrupt.windowStub.SiteDataReset.pendingDeletions())), - ['IELTSAtlasDataV2', 'ExamSystemDB', 'IELTSAtlasExternalBackupV2'], - 'an unparseable marker must fail safe because its affected names are unknown'); - const corruptRestricted = await corrupt.windowStub.SiteDataReset.perform({ reload: false }); - assert.equal(corruptRestricted.reason, 'recovery_confirmation_required'); - assert.equal(corruptRestricted.markerCorrupt, true); - const corruptRecovered = await corrupt.windowStub.SiteDataReset.perform({ - reload: false, - recoveryConfirmed: true - }); - assert.equal(corruptRecovered.success, true); -} - -/** `terminal` must describe what actually happened, not what usually happens. */ -async function testTerminalFlagReflectsWhetherTheReloadReallyHappened() { - const noReload = createHarness(); - const noReloadResult = await withDeadline( - noReload.windowStub.SiteDataReset.perform({ reload: false }), - 'successful reset without reload' - ); - assert.equal(noReloadResult.success, true); - assert.equal(noReload.windowStub.location.reloadCalls, 0, 'reload:false must not reload'); - assert.equal(noReloadResult.terminal, false, - 'terminal must be false when the page was never torn down'); - - const reloaded = createHarness(); - const reloadedResult = await withDeadline( - reloaded.windowStub.SiteDataReset.perform(), - 'successful reset with reload' - ); - assert.equal(reloaded.windowStub.location.reloadCalls, 1); - assert.equal(reloadedResult.terminal, true, 'terminal must be true when the page really reloaded'); - - // Same contract on the failure path. - const failedNoReload = createHarness({ deleteModes: { ExamSystemDB: 'error' } }); - const failedResult = await withDeadline( - failedNoReload.windowStub.SiteDataReset.perform({ reload: false }), - 'failed reset without reload' - ); - assert.equal(failedResult.success, false); - assert.equal(failedNoReload.windowStub.location.reloadCalls, 0); - assert.equal(failedResult.terminal, false, - 'a failed reset that did not reload must not claim the page is gone'); } -/** - * The singleton collapses duplicate clicks on one in-flight run; it is not a - * result cache. A settled non-terminal reset must be replayable for real. - */ -async function testSuccessfulNonTerminalResetCanRunAgain() { - const harness = createHarness(); - const first = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false }), - 'first non-terminal reset' - ); - assert.equal(first.success, true); - const deletesAfterFirst = harness.events.filter((entry) => entry.startsWith('delete:')).length; - assert.equal(deletesAfterFirst, 3); - - // The user writes new data, then asks for a clean slate a second time. - harness.writeFreshData('IELTSAtlasDataV2', 'record-written-after-first-reset'); - harness.localStorage.setItem('written-after-first-reset', '1'); - - const second = await withDeadline( - harness.windowStub.SiteDataReset.perform({ reload: false }), - 'second non-terminal reset' - ); - assert.equal(second.success, true); - assert.notEqual(second, first, 'the settled promise must not be replayed as the second result'); - assert.equal( - harness.events.filter((entry) => entry.startsWith('delete:')).length, - deletesAfterFirst + 3, - 'a second reset must actually re-issue every deleteDatabase request' - ); - assert.equal(harness.localStorage.clearCalls, 2, 'a second reset must actually clear web storage again'); - assert.equal(harness.localStorage.values.size, 0); - assert.deepEqual(harness.databaseContents.get('IELTSAtlasDataV2'), [], - 'data written between the two resets must really be gone'); +async function testConcurrentCallsShareOneRun() { + const harness = createHarness({ deleteModes: { IELTSAtlasDataV2: 'blocked' } }); + const first = harness.windowStub.SiteDataReset.perform({ reload: false }); + const second = harness.windowStub.SiteDataReset.perform({ reload: false }); + await flush(); + assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3); + assert.equal(harness.externalBackup.calls, 1); + harness.complete('IELTSAtlasDataV2'); + const [left, right] = await Promise.all([first, second]); + assert.equal(left, right); } -/** Concurrent clicks on one run still collapse into a single reset. */ -async function testConcurrentCallsShareOneInFlightReset() { +async function testFinishedNonTerminalRunCanRepeat() { const harness = createHarness(); - const [first, second] = await withDeadline( - Promise.all([ - harness.windowStub.SiteDataReset.perform({ reload: false }), - harness.windowStub.SiteDataReset.perform({ reload: false }) - ]), - 'concurrent resets' - ); - assert.equal(first.success, true); - assert.equal(second, first, 'overlapping calls must share the in-flight promise'); - assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3, - 'a double click must not delete every database twice'); - assert.equal(harness.externalBackup.prepareCalls, 1); -} - -async function main() { - await testCancelledResetHasNoSideEffects(); - await testSuccessfulResetReturnsToFreshBrowserState(); - await testBlockedDeletionWaitsAndWarns(); - await testDeletionFailureDoesNotClaimSuccessOrReload(); - await testExternalQuiesceFailureCannotBlockRecoveryReset(); - await testPendingExternalQuiesceTimesOutAndConcurrentCallsShareRecovery(); - await testQuiesceTimeoutNonTerminalResetCanRunAgain(); - await testPermanentlyBlockedDeletionTimesOutInsteadOfHanging(); - await testBlockedTimeoutStillCompletesTerminalCleanup(); - await testBlockedResetCanBeRetriedAfterOtherTabCloses(); - await testLateSuccessAfterTimeoutCannotResurrectTheResult(); - await testLateDeletionIsTrackedUntilTheBrowserRunsIt(); - await testSecondResetIsRefusedWhileADeletionIsStillPending(); - await testLiveDeletionNeverExpiresAndMarkerFailureStaysFailSafe(); - await testCrossRefreshMarkerRequiresConfirmedRecovery(); - await testStaleOrCorruptMarkerRequiresConfirmedRecovery(); - await testTerminalFlagReflectsWhetherTheReloadReallyHappened(); - await testSuccessfulNonTerminalResetCanRunAgain(); - await testConcurrentCallsShareOneInFlightReset(); - console.log('SiteDataReset tests passed'); -} - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); + assert.equal((await harness.windowStub.SiteDataReset.perform({ reload: false })).success, true); + assert.equal((await harness.windowStub.SiteDataReset.perform({ reload: false })).success, true); + assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 6); + assert.equal(harness.localStorage.clearCalls, 2); +} + +await testCancelledReset(); +await testSuccessfulReset(); +await testBlockedDeletionKeepsWaiting(); +await testDeletionFailureIsVisible(); +await testExternalFailureStopsBeforeDeletion(); +await testConcurrentCallsShareOneRun(); +await testFinishedNonTerminalRunCanRepeat(); +console.log('SiteDataReset tests passed'); diff --git a/js/app.js b/js/app.js index bd7fc6f4..f2f1acfc 100644 --- a/js/app.js +++ b/js/app.js @@ -165,23 +165,7 @@ class ExamSystemApp { } }, async initializeComponents() { - const optionalComponents = []; - try { - await this.initializeCoreComponents(); - if (optionalComponents.length > 0) { - try { - await this.waitForComponents(optionalComponents, 5000); - await this.initializeOptionalComponents(); - } catch (_) { - await this.initializeAvailableOptionalComponents(); - } - } else { - await this.initializeOptionalComponents(); - } - } catch (error) { - console.error('[App] 核心组件加载失败:', error); - throw error; - } + await this.initializeCoreComponents(); }, async initializeCoreComponents() { if (this.instantiatePracticeRecorder()) { @@ -372,7 +356,6 @@ class ExamSystemApp { } activeSessions.set(data.examId, existing); }, - handleRealPracticeData: async () => null, savePracticeRecord: async (record) => { const receipt = await window.AppData.practice.completeAttempt({ record }); return receipt && receipt.record ? receipt.record : null; @@ -404,43 +387,6 @@ class ExamSystemApp { this._practiceRecorderUpgradeTimer = setInterval(tryUpgrade, interval); tryUpgrade(); }, - async initializeOptionalComponents() {}, - async initializeAvailableOptionalComponents() { - const availableComponents = [].filter((name) => window[name]); - if (availableComponents.length > 0) { - await this.initializeOptionalComponents(); - } else { - console.warn('[App] 没有发现可用的可选组件'); - } - }, - async waitForComponents(requiredClasses = ['ExamBrowser'], timeout = 3000) { - const startTime = Date.now(); - const checkInterval = 100; - while (Date.now() - startTime < timeout) { - const loadingStatus = requiredClasses.map((className) => { - const isLoaded = window[className] && typeof window[className] === 'function'; - if (!isLoaded) { - console.debug(`[App] 等待组件: ${className}`); - } - return { className, isLoaded }; - }); - const allLoaded = loadingStatus.every((status) => status.isLoaded); - if (allLoaded) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, checkInterval)); - } - const missingClasses = requiredClasses.filter((className) => !window[className] || typeof window[className] !== 'function'); - const loadedClasses = requiredClasses.filter((className) => window[className] && typeof window[className] === 'function'); - const errorMessage = [ - `组件加载超时 (${timeout}ms)`, - `已加载: ${loadedClasses.join(', ') || '无'}`, - `缺失: ${missingClasses.join(', ')}`, - '请检查组件文件是否正确加载' - ].join('\n'); - console.error('[App] 组件加载失败:', errorMessage); - throw new Error(errorMessage); - } }; const integratedFallbackMixin = { diff --git a/js/app/examSessionMixin.js b/js/app/examSessionMixin.js index 84179ef2..4ccae441 100644 --- a/js/app/examSessionMixin.js +++ b/js/app/examSessionMixin.js @@ -2622,36 +2622,7 @@ } this.messageHandlers.set(examId, messageHandler); - // 向题目窗口发送初始化消息(兼容 0.2 增强器监听的 INIT_SESSION) - const sendInitEnvelope = async (targetWindow) => { - try { - const windowInfo = this.ensureExamWindowSession(examId, targetWindow); - if ( - windowInfo - && !windowInfo.reviewMode - && !windowInfo.suiteSessionId - && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' - && typeof this.getReadingDraftForExam === 'function' - ) { - try { - const restoredDraft = await this.getReadingDraftForExam(examId, { - sessionId: windowInfo.expectedSessionId - }); - if (restoredDraft) { - windowInfo.lastReadingDraft = restoredDraft; - this.examWindows && this.examWindows.set(examId, windowInfo); - } - } catch (_) { - // draft restore is best-effort - } - } - const initPayload = this._buildExamInitPayload(examId, windowInfo); - this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); - this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); - } catch (initError) { - console.warn('[App] 发送初始化消息失败:', initError); - } - }; + const sendInitEnvelope = (targetWindow) => this._sendExamInitEnvelope(examId, targetWindow); const tryAttachInitHandler = (targetWindow) => { if (!targetWindow || isFileProtocol) { @@ -2702,31 +2673,10 @@ if (examWindow && !examWindow.closed) { try { const windowInfo = this.ensureExamWindowSession(examId, examWindow); - if ( - windowInfo - && !windowInfo.reviewMode - && !windowInfo.suiteSessionId - && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' - && typeof this.getReadingDraftForExam === 'function' - ) { - try { - const restoredDraft = await this.getReadingDraftForExam(examId, { - sessionId: windowInfo.expectedSessionId - }); - if (restoredDraft) { - windowInfo.lastReadingDraft = restoredDraft; - } - } catch (_) { - // draft restore is best-effort - } - } - const initPayload = this._buildExamInitPayload(examId, windowInfo); windowInfo.handshakeAttempts = attempts + 1; windowInfo.lastHandshakeAt = Date.now(); this.examWindows && this.examWindows.set(examId, windowInfo); - // 直接发送两种事件名,确保增强器任何实现都能收到 - this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); - this._postExamMessage(examId, examWindow, 'init_exam_session', initPayload); + await this._sendExamInitEnvelope(examId, examWindow); } catch (_) { /* 忽略 */ } } attempts++; diff --git a/js/bundles/browse.bundle.js b/js/bundles/browse.bundle.js index 4d22c3ea..137d6103 100644 --- a/js/bundles/browse.bundle.js +++ b/js/bundles/browse.bundle.js @@ -9510,36 +9510,7 @@ } this.messageHandlers.set(examId, messageHandler); - // 向题目窗口发送初始化消息(兼容 0.2 增强器监听的 INIT_SESSION) - const sendInitEnvelope = async (targetWindow) => { - try { - const windowInfo = this.ensureExamWindowSession(examId, targetWindow); - if ( - windowInfo - && !windowInfo.reviewMode - && !windowInfo.suiteSessionId - && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' - && typeof this.getReadingDraftForExam === 'function' - ) { - try { - const restoredDraft = await this.getReadingDraftForExam(examId, { - sessionId: windowInfo.expectedSessionId - }); - if (restoredDraft) { - windowInfo.lastReadingDraft = restoredDraft; - this.examWindows && this.examWindows.set(examId, windowInfo); - } - } catch (_) { - // draft restore is best-effort - } - } - const initPayload = this._buildExamInitPayload(examId, windowInfo); - this._postExamMessage(examId, targetWindow, 'INIT_SESSION', initPayload); - this._postExamMessage(examId, targetWindow, 'init_exam_session', initPayload); - } catch (initError) { - console.warn('[App] 发送初始化消息失败:', initError); - } - }; + const sendInitEnvelope = (targetWindow) => this._sendExamInitEnvelope(examId, targetWindow); const tryAttachInitHandler = (targetWindow) => { if (!targetWindow || isFileProtocol) { @@ -9590,31 +9561,10 @@ if (examWindow && !examWindow.closed) { try { const windowInfo = this.ensureExamWindowSession(examId, examWindow); - if ( - windowInfo - && !windowInfo.reviewMode - && !windowInfo.suiteSessionId - && String(windowInfo.practiceMode || '').toLowerCase() !== 'memorize' - && typeof this.getReadingDraftForExam === 'function' - ) { - try { - const restoredDraft = await this.getReadingDraftForExam(examId, { - sessionId: windowInfo.expectedSessionId - }); - if (restoredDraft) { - windowInfo.lastReadingDraft = restoredDraft; - } - } catch (_) { - // draft restore is best-effort - } - } - const initPayload = this._buildExamInitPayload(examId, windowInfo); windowInfo.handshakeAttempts = attempts + 1; windowInfo.lastHandshakeAt = Date.now(); this.examWindows && this.examWindows.set(examId, windowInfo); - // 直接发送两种事件名,确保增强器任何实现都能收到 - this._postExamMessage(examId, examWindow, 'INIT_SESSION', initPayload); - this._postExamMessage(examId, examWindow, 'init_exam_session', initPayload); + await this._sendExamInitEnvelope(examId, examWindow); } catch (_) { /* 忽略 */ } } attempts++; @@ -16498,12 +16448,6 @@ async function initializeLegacyComponents() { setupBrowsePreferenceUI(); - // Setup UI Listeners - const folderPicker = document.getElementById('folder-picker'); - if (folderPicker) { - folderPicker.addEventListener('change', handleFolderSelection); - } - // Initialize components if (window.PDFHandler) { pdfHandler = new PDFHandler(); @@ -19172,9 +19116,6 @@ async function setActiveLibraryConfiguration(key) { return await manager.setActiveLibraryConfiguration(key); } } -function triggerFolderPicker() { document.getElementById('folder-picker').click(); } -function handleFolderSelection(event) { /* legacy stub - replaced by modal-specific inputs */ } - // --- Library Loader Modal and Index Management --- // ... other utility and management functions can be moved here ... // --- Functions Restored from Backup --- diff --git a/js/bundles/core-foundation.bundle.js b/js/bundles/core-foundation.bundle.js index 66ffd500..f354fbf0 100644 --- a/js/bundles/core-foundation.bundle.js +++ b/js/bundles/core-foundation.bundle.js @@ -1698,6 +1698,7 @@ } return ''; } + function importedLibraryId(value, options = {}) { const id = value === null || value === undefined ? '' : String(value).trim(); if (!id && options.nullable) return null; @@ -1766,143 +1767,92 @@ }; } - function nonNegativeScalar(value) { - if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; - if (typeof value !== 'number' && typeof value !== 'string') return null; - const numeric = Number(value); - return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; - } - - function firstNonNegativeScalar(...values) { + function firstNonNegative(...values) { for (const value of values) { - const numeric = nonNegativeScalar(value); - if (numeric !== null) return numeric; + if (value === null || value === undefined || value === '' || typeof value === 'object') continue; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } return null; } - function normalizeAnswerQuestionId(value, index = 0) { - const text = value === undefined || value === null ? '' : String(value).trim(); - return text || `q${index + 1}`; - } - - function normalizeAnswerValue(value) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); - if (value && typeof value === 'object') { - if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); - if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); - if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); - return jsonValue(value, 'practice answer'); + function normalizePracticeScore(record) { + const scoreInfo = asObject(record.scoreInfo); + const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo); + const overloadedAnswers = record.correctAnswers; + if (overloadedAnswers && typeof overloadedAnswers === 'object') { + record.correctAnswerMap = Object.assign( + {}, + clone(asObject(overloadedAnswers)), + clone(asObject(record.correctAnswerMap)) + ); } - return typeof value === 'string' ? value : String(value); + const correct = firstNonNegative( + overloadedAnswers, + record.correctAnswersCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + legacyScoreInfo.correctAnswers, + legacyScoreInfo.correct + ); + if (correct !== null) record.correctAnswers = correct; + else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0; + const total = firstNonNegative( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + legacyScoreInfo.totalQuestions, + legacyScoreInfo.total + ); + if (total !== null) record.totalQuestions = total; } - function normalizeAnswerMap(value) { - const normalized = {}; - if (Array.isArray(value)) { - value.forEach((entry, index) => { - if (entry === undefined || entry === null) return; - const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; - const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); - normalized[questionId] = normalizeAnswerValue( - hasOwn(item, 'answer') ? item.answer - : hasOwn(item, 'userAnswer') ? item.userAnswer - : hasOwn(item, 'value') ? item.value - : entry - ); + function mergeAnswers(target, source) { + if (Array.isArray(source)) { + source.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1); + const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer; + if (answer !== undefined) target[questionId] = clone(answer); }); - return normalized; + return; } - if (!value || typeof value !== 'object') return normalized; - Object.entries(value).forEach(([questionId, answer], index) => { - if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; - normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); - }); - return normalized; - } - - function mergeAnswerMaps(...sources) { - const merged = {}; - for (const source of sources) { - const normalized = normalizeAnswerMap(source); - for (const [questionId, answer] of Object.entries(normalized)) { - if (!hasOwn(merged, questionId)) merged[questionId] = answer; - } + for (const [questionId, answer] of Object.entries(asObject(source))) { + target[String(questionId)] = clone(answer); } - return merged; } - function canonicalizeAnswerSource(record) { - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const rawRealData = asObject(rawData.realData); - record.answers = mergeAnswerMaps( - record.answers, - record.answerMap, - record.answerList, - realData.answers, - realData.answerMap, - rawData.answers, - rawData.answerMap, - rawRealData.answers - ); - // These names remain accepted only at the compatibility boundary. The - // canonical detail layer owns one user-answer source: `answers`. - delete record.answerMap; - delete record.answerList; + function normalizePracticeAnswers(record) { + const answers = {}; + const raw = asObject(record.rawData); + const rawReal = asObject(raw.realData); + const real = asObject(record.realData); + for (const source of [ + rawReal.answerMap, rawReal.answerList, rawReal.answers, + raw.answerMap, raw.answerList, raw.answers, + real.answerMap, real.answerList, real.answers, + record.answerMap, record.answerList, record.answers + ]) mergeAnswers(answers, source); + if (Object.keys(answers).length) record.answers = answers; } - function normalizePracticeScoreFields(record) { - const scoreInfo = asObject(record.scoreInfo); - const realScoreInfo = asObject(asObject(record.realData).scoreInfo); - const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); - const overloadedCorrectAnswers = record.correctAnswers; - const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; - if (isAnswerMap) { - const existingMap = record.correctAnswerMap; - const overloadedObject = asObject(overloadedCorrectAnswers); - const existingObject = asObject(existingMap); - if (Object.keys(overloadedObject).length) { - record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); - } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { - record.correctAnswerMap = clone(overloadedCorrectAnswers); - } - } - - let correctAnswers = firstNonNegativeScalar( - overloadedCorrectAnswers, - record.correctAnswersCount, - record.correctCount, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct, - rawScoreInfo.correctAnswers, - rawScoreInfo.correct - ); - if (correctAnswers === null) { - const comparisons = asArray(record.answerComparison).length - ? asArray(record.answerComparison) - : asArray(asObject(record.realData).answerComparison); - if (comparisons.length) { - correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; - } + function questionTypeErrorCounts(source) { + const counts = {}; + const add = (type, count = 1) => { + const key = String(type || '').trim(); + if (key && count > 0) counts[key] = (counts[key] || 0) + count; + }; + for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) { + const metrics = asObject(value); + const total = firstNonNegative(metrics.totalQuestions, metrics.total); + const correct = firstNonNegative(metrics.correctAnswers, metrics.correct); + if (total !== null && correct !== null) add(type, Math.max(0, total - correct)); } - if (correctAnswers !== null) record.correctAnswers = correctAnswers; - else if (isAnswerMap) record.correctAnswers = 0; - - const totalQuestions = firstNonNegativeScalar( - record.totalQuestions, - record.questionCount, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total, - rawScoreInfo.totalQuestions, - rawScoreInfo.total - ); - if (totalQuestions !== null) record.totalQuestions = totalQuestions; + for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) { + if (detail && detail.isCorrect === false) add(detail.questionType || detail.type); + } + return counts; } function canonicalizeRecord(input) { @@ -1916,8 +1866,8 @@ record.metadata = asObject(record.metadata); if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; - canonicalizeAnswerSource(record); - normalizePracticeScoreFields(record); + normalizePracticeAnswers(record); + normalizePracticeScore(record); for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { if (record[field] === undefined || record[field] === null || record[field] === '') continue; const numeric = Number(record[field]); @@ -1928,97 +1878,13 @@ return jsonValue(record, 'canonical practice record'); } - function deriveQuestionTypeErrorCounts(source) { - const record = asObject(source); - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const counts = {}; - const addCount = (type, value) => { - const key = String(type || 'other').trim() || 'other'; - const amount = Math.max(0, Number(value) || 0); - if (amount > 0) counts[key] = (counts[key] || 0) + amount; - }; - const performanceSources = [ - record.questionTypePerformance, - realData.questionTypePerformance, - rawData.questionTypePerformance - ]; - let hasPerformanceData = false; - for (const performanceMap of performanceSources) { - if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; - let sourceHasPerformanceData = false; - for (const [type, value] of Object.entries(performanceMap)) { - const performance = asObject(value); - const total = Number(performance.total ?? performance.totalQuestions); - const correct = Number(performance.correct ?? performance.correctAnswers); - if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; - hasPerformanceData = true; - sourceHasPerformanceData = true; - addCount(type, total - correct); - } - if (sourceHasPerformanceData) break; - } - if (hasPerformanceData) return counts; - - const questionTypeMap = Object.assign( - {}, - asObject(rawData.questionTypeMap), - asObject(realData.questionTypeMap), - asObject(record.questionTypeMap) - ); - const normalizedTypeMap = {}; - for (const [questionId, type] of Object.entries(questionTypeMap)) { - normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; - } - const detailSources = [ - record.answerDetails, - asObject(record.scoreInfo).details, - realData.answerDetails, - asObject(realData.scoreInfo).details, - rawData.answerDetails, - asObject(rawData.scoreInfo).details - ]; - const seenQuestions = new Set(); - for (const details of detailSources) { - if (!details || typeof details !== 'object' || Array.isArray(details)) continue; - for (const [questionId, value] of Object.entries(details)) { - const detail = asObject(value); - const normalizedId = String(questionId).trim().toLowerCase(); - if (!normalizedId || seenQuestions.has(normalizedId)) continue; - let isWrong = detail.isCorrect === false || detail.correct === false; - if (detail.isCorrect === true || detail.correct === true) isWrong = false; - else if (!isWrong) { - const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); - const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); - isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); - } - if (!isWrong) continue; - seenQuestions.add(normalizedId); - addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); - } - } - return counts; - } - function lightSuiteEntry(source, fallbackType = null) { const entry = asObject(source); const scoreInfo = asObject(entry.scoreInfo); const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); const metadata = asObject(entry.metadata); - const totalQuestions = firstNonNegativeScalar( - entry.totalQuestions, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total - ) ?? 0; - const correctAnswers = firstNonNegativeScalar( - entry.correctAnswers, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct - ) ?? 0; + const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0; + const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0; const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; const accuracy = normalizeAccuracyRatio( explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), @@ -2030,14 +1896,14 @@ sessionId: entry.sessionId || null, examId: entry.examId || metadata.examId || null, title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', - type: entry.type || metadata.type || metadata.examType || fallbackType || null, + type: entry.type || metadata.type || fallbackType, date: entry.date || entry.completedAt || entry.timestamp || null, duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, totalQuestions, correctAnswers, accuracy, percentage, - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + questionTypeErrorCounts: questionTypeErrorCounts(entry) }, 'suite entry light projection'); } @@ -2074,6 +1940,7 @@ accuracy, percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + questionTypeErrorCounts: questionTypeErrorCounts(source), // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 @@ -2087,8 +1954,10 @@ 'dataSource', 'source', 'libraryConfigurationId' ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), suite: source.suite == null ? null : clone(asObject(source.suite)), - suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry( + entry, + String(source.type || '').replace(/-suite$/, '') || null + )) }, 'practice light projection'); } @@ -2109,7 +1978,7 @@ return first === undefined ? {} : clone(first); } - const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']); const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); function withoutRawData(value) { @@ -2128,11 +1997,10 @@ const detail = { recordId: source.id }; const annotations = { recordId: source.id }; for (const [key, value] of Object.entries(source)) { - if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue; if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { const next = Object.assign({}, asObject(entry)); - canonicalizeAnswerSource(next); const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); @@ -2154,7 +2022,7 @@ } // Accept the old mirror only as an input normalization boundary; it is never persisted. const realData = asObject(source.realData); const rawData = asObject(source.rawData); - for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); } for (const key of ANNOTATION_FIELDS) { @@ -2367,8 +2235,6 @@ // Entity records are authoritative. Projections are assembled on reads, never cached or // scheduled as follow-up work; this keeps a successful write immediately observable. - async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } - async function retryMergeConflict(options, task, maxAttempts = 3) { const explicitRevision = hasOwn(options, 'expectedRevision'); let lastError; @@ -2468,51 +2334,10 @@ function practiceLayerId(row) { return String(row && (row.recordId || row.id || row.sessionId) || ''); } - async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { - // The real kernel reads all three entity stores in one readonly - // IndexedDB transaction. Keep the fallback for deliberately minimal - // embedders and unit-test kernels that only expose the original methods. - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); - } - const ids = recordIds === null || recordIds === undefined - ? null - : (Array.isArray(recordIds) ? recordIds : [recordIds]) - .map((value) => String(value || '')) - .filter(Boolean); - const summaries = ids === null - ? await kernel.listEntities('practiceSummaries', { withMeta }) - : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); - const targetIds = summaries.map(practiceLayerId).filter(Boolean); - const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; - const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) - .then((rows) => rows.filter(Boolean)); - return { - practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], - practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], - practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] - }; - } async function practiceLayers(recordId, withMeta = false) { - const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta }); const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; - return { - summary: find('practiceSummaries'), - detail: find('practiceDetails'), - annotations: find('practiceAnnotations') - }; - } - async function suiteChildRecordIds(command, aggregateRecordId) { - const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); - const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); - if (sessionIds.size) { - const summaries = await kernel.listEntities('practiceSummaries'); - for (const summary of summaries) { - if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); - } - } - ids.delete(String(aggregateRecordId)); - return ids; + return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') }; } function entityRevision(row) { return row ? Number(row.revision) : 0; } function practiceUpserts(recordId, layers, existing = {}) { @@ -2524,76 +2349,30 @@ } async function joinedPractice(recordId, projection, snapshot = null) { const mode = String(projection || 'full').toLowerCase(); - const layers = snapshot || await practiceProjectionSnapshot([recordId], false, - mode === 'light' || mode === 'summary' - ? ['practiceSummaries'] - : (mode === 'detail' || mode === 'medium' - ? ['practiceSummaries', 'practiceDetails'] - : null)); - const summary = asArray(layers.practiceSummaries) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const stores = mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined); + const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores }); + const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + const summary = find('practiceSummaries'); if (!summary) return null; if (mode === 'light' || mode === 'summary') return clone(summary); - const detail = asArray(layers.practiceDetails) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const detail = find('practiceDetails'); if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); - const annotations = asArray(layers.practiceAnnotations) - .find((row) => practiceLayerId(row) === String(recordId)) || null; - return joinPracticeRecord(summary, detail, annotations, mode); - } - function practiceSummaryTime(summary) { - const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); - return Number.isFinite(time) ? time : 0; - } - function isReadingInsightSummary(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => practiceType(entry) === 'reading'); - } - return practiceType(asObject(summary)) === 'reading'; - } - function needsQuestionTypeInsightBackfill(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => - practiceType(entry) === 'reading' - && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); - } - return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode); } const practice = Object.freeze({ async list(options = {}) { await ready; const projection = String(options.projection || 'full').toLowerCase(); - const snapshot = await practiceProjectionSnapshot(null, false, - projection === 'light' || projection === 'summary' - ? ['practiceSummaries'] - : null); - const summaries = asArray(snapshot.practiceSummaries); + const summaries = await kernel.listEntities('practiceSummaries'); if (projection === 'light' || projection === 'summary') return summaries; - return (await Promise.all(summaries - .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) - .filter(Boolean); - }, - async listInsights(options = {}) { - await ready; - const requestedLimit = Number(options.limit); - const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 - ? Math.min(requestedLimit, 50) - : 10; - const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); - const summaries = asArray(snapshot.practiceSummaries) - .filter(isReadingInsightSummary) - .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) - .slice(0, limit); - return summaries.map((summary) => { - if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); - const detail = asArray(snapshot.practiceDetails) - .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; - return detail - ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) - : clone(summary); - }); + const stores = projection === 'detail' || projection === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : undefined; + const snapshot = await kernel.readPracticeSnapshot(null, { stores }); + return (await Promise.all(asArray(snapshot.practiceSummaries) + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean); }, async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, async completeAttempt(command) { @@ -2613,9 +2392,13 @@ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String); + const children = new Set((await kernel.listEntities('practiceSummaries')) + .filter((summary) => practiceRecordMatches(summary, childIdentities)) + .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId']))); + children.delete(recordId); const receipt = await retryMergeConflict(command, async () => { const existing = await practiceLayers(recordId, true); - const children = await suiteChildRecordIds(command, recordId); const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); }); @@ -2658,6 +2441,22 @@ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); }, async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async listInsights(options = {}) { + await ready; + const limit = Math.max(1, Math.min(50, Number(options.limit) || 10)); + const summaries = (await kernel.listEntities('practiceSummaries')) + .slice() + .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '') + .localeCompare(String(left.date || left.completedAt || left.timestamp || ''))) + .slice(0, limit); + return Promise.all(summaries.map(async (summary) => { + if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary); + const detail = await kernel.readEntity('practiceDetails', summary.id); + return jsonValue(Object.assign({}, clone(summary), { + questionTypeErrorCounts: questionTypeErrorCounts(detail) + }), 'practice insight'); + })); + }, async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, projectLight, projectDetail @@ -2905,88 +2704,6 @@ 'library.activeConfigurationId' ]); - function parseLegacyImportValue(value) { - if (typeof internals.parseLegacyValue !== 'function') { - throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); - } - return internals.parseLegacyValue(value); - } - - function decodePoisonedDocument(logicalKey, wrapped) { - const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; - if (!aliases || !isPlainImportObject(wrapped) - || !Object.prototype.hasOwnProperty.call(wrapped, 'key') - || !Object.prototype.hasOwnProperty.call(wrapped, 'value') - || !aliases.includes(String(wrapped.key))) { - return { matched: false, value: null }; - } - const decoded = parseLegacyImportValue(wrapped.value); - if (!isPlainImportObject(decoded)) return { matched: true, value: null }; - const overlay = {}; - for (const [key, value] of Object.entries(wrapped)) { - if (key === 'key' || key === 'value' || key === 'timestamp') continue; - overlay[key] = clone(value); - } - return { - matched: true, - value: Object.assign({}, decoded, overlay) - }; - } - - function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { - if (!envelope || envelope.state !== 'present') return envelope; - const wrapped = envelope.data; - const decoded = decodePoisonedDocument(logicalKey, wrapped); - if (!decoded.matched || !decoded.value) return envelope; - const entry = catalog.get(logicalKey); - const next = internals.makeEnvelope(entry, decoded.value, { - revision: Number(envelope.revision) || 1, - operationId: String(envelope.operationId || randomId('import-repair')), - updatedAt: envelope.updatedAt - }); - repairedKeys.push(logicalKey); - warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); - return next; - } - - function validateLibraryImportBundle(envelopes) { - const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); - if (!presentKeys.length) return { valid: true, presentKeys }; - if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { - return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; - } - const values = {}; - for (const key of LIBRARY_IMPORT_KEYS) { - const envelope = envelopes[key]; - values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; - } - const configurations = asArray(values['library.configurations']); - const indexes = asObject(values['library.importedIndexes']); - const configurationIds = new Set(); - for (const configuration of configurations) { - const source = asObject(configuration); - const id = idOf(source, ['id', 'key', 'configId']); - if (!id || !acceptedLibraryId(id) || source.builtIn === true - || !Array.isArray(indexes[id]) || !indexes[id].length) { - return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; - } - configurationIds.add(id); - } - for (const [id, index] of Object.entries(indexes)) { - if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { - return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; - } - } - const activeId = values['library.activeConfigurationId']; - if (activeId !== null && (!acceptedLibraryId(activeId) - || !configurationIds.has(String(activeId)) - || !Array.isArray(indexes[String(activeId)]) - || !indexes[String(activeId)].length)) { - return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; - } - return { valid: true, presentKeys }; - } - function canonicalizeV2Import(parsed) { const warnings = []; const repairedKeys = []; @@ -2994,41 +2711,43 @@ const envelopes = {}; for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); - if (logicalKey === 'library.activeConfigurationId' - && rawEnvelope && rawEnvelope.state === 'present' - && String(rawEnvelope.data) === '[object Object]') { + const envelope = clone(rawEnvelope); + const data = envelope && envelope.state === 'present' ? envelope.data : null; + if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') { ignoredKeys.push(logicalKey); warnings.push('Skipped poisoned active library id'); continue; } - const repairCount = repairedKeys.length; - const repaired = repairPoisonedImportEnvelope( - logicalKey, - clone(rawEnvelope), - repairedKeys, - warnings - ); - const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; - const isLegacyRowWrapper = isPlainImportObject(rawData) - && Object.prototype.hasOwnProperty.call(rawData, 'key') - && Object.prototype.hasOwnProperty.call(rawData, 'value') - && String(rawData.key || '').startsWith('exam_system_'); - if (isLegacyRowWrapper && repairedKeys.length === repairCount) { - ignoredKeys.push(logicalKey); - warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); - continue; - } - envelopes[logicalKey] = repaired; - } - const library = parsed.scope === 'full' - ? validateLibraryImportBundle(envelopes) - : { valid: true, presentKeys: [] }; - if (!library.valid) { - for (const logicalKey of library.presentKeys) { - delete envelopes[logicalKey]; - ignoredKeys.push(logicalKey); + if (isPlainImportObject(data) + && Object.prototype.hasOwnProperty.call(data, 'key') + && Object.prototype.hasOwnProperty.call(data, 'value') + && String(data.key || '').startsWith('exam_system_')) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || []; + const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null; + if (!isPlainImportObject(decoded)) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + const overlay = Object.fromEntries(Object.entries(data) + .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp')); + envelope.data = Object.assign({}, decoded, overlay); + envelope.checksum = checksum(envelope.data); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + } + envelopes[logicalKey] = envelope; + } + + if (parsed.scope === 'full') { + const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) { + for (const key of presentLibraryKeys) { + delete envelopes[key]; + ignoredKeys.push(key); + } + warnings.push('Skipped incomplete library data'); } - warnings.push(`Skipped unsafe library data: ${library.reason}`); } const exportableKeys = catalog.list() .filter((entry) => entry.export === true && isImportableEntry(entry)) @@ -3036,9 +2755,7 @@ const missingKeys = parsed.scope === 'full' ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) : []; - if (parsed.scope === 'full' && missingKeys.length) { - warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); - } + const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length); return { envelopes, warnings, @@ -3046,10 +2763,8 @@ ignoredKeys, missingKeys, declaredScope: parsed.scope, - effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, - trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length - ? 'trusted-full' - : 'degraded-partial' + effectiveScope: degraded ? 'partial' : parsed.scope, + trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial') }; } @@ -3310,9 +3025,6 @@ } async function currentEntitySnapshot() { - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(null, { withMeta: true }); - } const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); const result = {}; for (const store of PRACTICE_ENTITY_STORES) { @@ -3349,19 +3061,31 @@ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); continue; } - const currentRead = await kernel.read(logicalKey, { withMeta: true }); - const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; - const currentEnvelope = currentRead && currentRead.envelope; - revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + const current = await kernel.read(logicalKey, { withMeta: true }); + revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0; let next = envelope; if (!replaceDocuments && envelope.state === 'present') { - next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') }); } snapshot.envelopes[logicalKey] = next; keys.push(logicalKey); if (next.state === 'cleared') clearedKeys.push(logicalKey); } + // A full replace mirrors all exportable user data. Missing physical + // envelopes mean catalog defaults, represented here as explicit clears. + if (replaceDocuments && parsed.scope === 'full') { + for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) { + if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue; + snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, { + state: 'cleared', + operationId: randomId('import-clear') + }); + keys.push(entry.logicalKey); + clearedKeys.push(entry.logicalKey); + } + } + // Any successful practice import installs all three stores together. Merge // may update a subset only when the final recordId sets remain identical. const sourceStores = Object.keys(asObject(parsed.entities)); @@ -3574,7 +3298,7 @@ const mutation = optionsMutationOptions(options, 'vocab-words', words); return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.words', { withMeta: true }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.words', data: words, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3616,7 +3340,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3631,7 +3355,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), upserts); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3658,7 +3382,7 @@ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); list.updatedAt = nowIso(); collections[id] = list; - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3676,7 +3400,7 @@ const current = await kernel.read('vocab.lists', { withMeta: true }); const collections = Object.assign({}, asObject(current.data)); collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3742,7 +3466,7 @@ { id: listId, words: merged, updatedAt: nowIso() } ) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3775,7 +3499,7 @@ : Object.assign({}, collections, { [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) @@ -3802,7 +3526,7 @@ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - return mutateAndProject(changes, mutation); + return kernel.mutate(changes, mutation); }); } }); @@ -3937,253 +3661,95 @@ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], 'achievements.manual': ['achievement_manual_state', 'user_achievements'] }); - const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ - 'recovery.activeSessions', - 'recovery.drafts', - 'recovery.interrupted', - 'recovery.rejectedCompletions' - ]); const LEGACY_PREFERENCE_ALIASES = Object.freeze({ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' }); - function legacyRecordArray(value) { - return Array.isArray(value) ? value : asArray(asObject(value).data); - } - function mergeLegacyExternalBackup(legacyValue, externalValue) { - const legacy = Object.assign({}, asObject(legacyValue)); + function mergeLegacySources(indexedDbValue, externalValue) { + const indexedDb = asObject(indexedDbValue); const external = asObject(externalValue); - const externalRecordKey = ['practice_records', 'practiceRecords'] - .find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (externalRecordKey) { - const records = new Map(); - for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { - const recordId = idOf(record, ['id', 'recordId', 'sessionId']); - records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); - } - legacy.practice_records = Array.from(records.values()); - } - for (const [target, aliases] of Object.entries({ - user_stats: ['user_stats', 'userStats'], - exam_index: ['exam_index', 'examIndex'], - storage_version: ['storage_version', 'storageVersion'] - })) { - if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; - const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (alias) legacy[target] = clone(external[alias]); - } - return legacy; - } - - function acceptedLibraryId(value) { - const id = value === null || value === undefined ? '' : String(value).trim(); - if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; - try { return importedLibraryId(id); } catch (_) { return null; } - } - - function remapLegacyLibraryId(value) { - return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + const merged = Object.assign({}, external, indexedDb); + const records = new Map(); + const addRecords = (value) => { + const list = Array.isArray(value) ? value : asArray(asObject(value).data); + list.forEach((record) => { + const id = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record)); + }); + }; + addRecords(external.practice_records || external.practiceRecords); + addRecords(indexedDb.practice_records); + if (records.size) merged.practice_records = Array.from(records.values()); + return merged; } - async function migrateLegacyLibraryData(legacy) { - const [configMeta, indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.configurations', { withMeta: true }), - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const legacyIdMap = new Map(); + function legacyLibraryBundle(legacy) { + const idMap = new Map(); const indexes = {}; - const addLegacyIndex = (oldId, value) => { - if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; - const index = asArray(value); - if (!index.length) return; - const mappedId = remapLegacyLibraryId(oldId); - legacyIdMap.set(oldId, mappedId); - indexes[mappedId] = clone(index); - }; - - for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); - // Reconciliation is a union. A healthy current v2 value wins for the same - // deterministic library ID, while missing legacy libraries are restored. - for (const [id, value] of Object.entries(asObject(indexMeta.data))) { - if (/^exam_index_/.test(id)) addLegacyIndex(id, value); - else { - const acceptedId = acceptedLibraryId(id); - if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); - } + for (const [oldId, value] of Object.entries(asObject(legacy))) { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue; + const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`; + idMap.set(oldId, id); + indexes[id] = clone(value); } - + if (!idMap.size) return null; const configurations = new Map(); - const addConfiguration = (configuration) => { - const source = asObject(configuration); - const oldId = idOf(source, ['id', 'key', 'configId']); - if (!oldId || oldId === 'exam_index') return; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - if (!id || !asArray(indexes[id]).length) return; - configurations.set(id, Object.assign({}, clone(source), { + asArray(legacy.exam_index_configurations).forEach((configuration) => { + const oldId = idOf(configuration, ['id', 'key', 'configId']); + const id = idMap.get(oldId); + if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length })); + }); + for (const [oldId, id] of idMap) { + if (!configurations.has(id)) configurations.set(id, { id, key: id, - examCount: indexes[id].length - })); - }; - asArray(legacy.exam_index_configurations).forEach(addConfiguration); - asArray(configMeta.data).forEach(addConfiguration); - for (const [oldId, id] of legacyIdMap) { - if (!configurations.has(id)) { - configurations.set(id, { - id, - key: id, - name: `迁移的自定义题库 (${oldId})`, - examCount: indexes[id].length, - sourceType: 'legacy-import' - }); - } - } - - const resolveActive = (value) => { - const oldId = value === null || value === undefined ? '' : String(value).trim(); - if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - return id && asArray(indexes[id]).length ? id : null; - }; - const currentRawActive = activeMeta.data; - let activeId = resolveActive(currentRawActive); - const currentIsExplicitDefault = Boolean(activeMeta.envelope) - && (currentRawActive === null || String(currentRawActive || '').trim() === ''); - if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { - activeId = resolveActive(legacy.active_exam_index_key); - } - - const nextConfigurations = Array.from(configurations.values()); - const changes = []; - if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { - changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); - } - if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { - changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); - } - if (activeId !== activeMeta.data) { - changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); - } - if (changes.length) { - await kernel.mutate(changes, { - operationId: `legacy-library-repair-v2-${checksum(changes)}` + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' }); } + return { + configurations: Array.from(configurations.values()), + indexes, + activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null + }; } async function migrateLegacyData() { // Unit embedders may provide a deliberately minimal kernel bootstrap. if (typeof internals.readLegacyValues !== 'function') return; - const legacySource = await internals.readLegacyValues(); - if (legacySource && legacySource.__legacyReadComplete === false) { - throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); - } const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); const migrationState = asObject(migrationMeta.data); + const v1Complete = asObject(migrationState.v1ToV2).status === 'complete'; + const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed'; let externalBackup = null; - if (asObject(migrationState.externalBackupV1).status !== 'consumed' - && typeof internals.readLegacyExternalBackup === 'function') { - try { - externalBackup = await internals.readLegacyExternalBackup(); - } catch (error) { + if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') { + try { externalBackup = await internals.readLegacyExternalBackup(); } + catch (error) { if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } - const legacy = externalBackup - ? mergeLegacyExternalBackup(legacySource, externalBackup) - : legacySource; - if (!legacy || !Object.keys(legacy).length) return; + if (v1Complete && !externalBackup) return; - const documentMetas = {}; - for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { - documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); - } - const [indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const documentRepairs = []; - const poisonedDocumentKeys = []; - for (const [logicalKey, current] of Object.entries(documentMetas)) { - if (!current.envelope || current.envelope.state !== 'present') continue; - const decoded = decodePoisonedDocument(logicalKey, current.data); - if (!decoded.matched) continue; - poisonedDocumentKeys.push(logicalKey); - const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; - const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - const legacyValue = legacyAlias ? legacy[legacyAlias] : null; - let repairValue = decoded.value; - if (isPlainImportObject(legacyValue)) { - repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); - } - if (!repairValue) continue; - documentRepairs.push({ - logicalKey, - data: repairValue, - expectedRevision: Number(current.envelope.revision) - }); - } - const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => - isPlainImportObject(value) - && Object.prototype.hasOwnProperty.call(value, 'key') - && Object.prototype.hasOwnProperty.call(value, 'value') - && /^exam_system_exam_index_/.test(String(value.key || ''))); - const poisonedActive = String(activeMeta.data) === '[object Object]'; - const libraryPoisoned = poisonedIndex || poisonedActive; - const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; - - await migrateLegacyLibraryData(legacy); - if (documentRepairs.length) { - await kernel.mutate(documentRepairs, { - operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` - }); + const indexedDb = await internals.readLegacyValues(); + if (indexedDb && indexedDb.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); } - + const legacy = mergeLegacySources(indexedDb, externalBackup); const changes = []; for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { - const currentAudit = asObject(migrationState.v1ToV2); - if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { - continue; - } const current = await kernel.getEnvelope(logicalKey); + if (current) continue; const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - if (!alias) continue; - const legacyValue = legacy[alias]; - if (!current) { - changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); - continue; - } - const isBadMigrationWrite = current.state === 'present' - && /^legacy-documents-/.test(String(current.operationId || '')); - if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { - changes.push({ - logicalKey, - data: legacyValue, - expectedRevision: Number(current.revision) - }); - continue; - } - const entry = catalog.get(logicalKey); - if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; - let merged; - try { - merged = mergeImportValue(entry, legacyValue, current.data); - } catch (error) { - if (global.console && console.warn) { - console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); - } - continue; - } - if (checksum(merged) !== checksum(current.data)) { - changes.push({ - logicalKey, - data: merged, - expectedRevision: Number(current.revision) - }); - } + if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 }); + } + const libraryBundle = legacyLibraryBundle(legacy); + if (libraryBundle) { + if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 }); } if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { const preferences = {}; @@ -4198,84 +3764,52 @@ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); } - if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` }); const recordsValue = legacy.practice_records; const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); const operations = []; - let skippedRecords = 0; - const reconciledRecordIds = new Set(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; - let canonical; - let parts; + for (const [index, record] of records.entries()) { try { - const candidate = jsonValue(record, 'legacy practice record'); - if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { - candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const candidate = clone(record); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const canonical = canonicalizeRecord(candidate); + const parts = splitPracticeRecord(canonical); + for (const [store, data] of [ + ['practiceSummaries', parts.summary], + ['practiceDetails', parts.detail], + ['practiceAnnotations', parts.annotations] + ]) { + if (!await kernel.readEntity(store, canonical.id)) { + operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 }); + } } - canonical = canonicalizeRecord(candidate); - parts = splitPracticeRecord(canonical); } catch (error) { - skippedRecords += 1; if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); - continue; } - if (reconciledRecordIds.has(canonical.id)) continue; - reconciledRecordIds.add(canonical.id); - // Storage errors are not malformed records. Let them abort this repair so the - // completion marker is not written and the next startup can retry safely. - const existing = await practiceLayers(canonical.id, true); - if (existing.summary && existing.detail && existing.annotations) continue; - operations.push(...practiceUpserts(canonical.id, parts, existing)); } if (operations.length) { - await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` }); } - const migrationAudit = { - version: 4, + + const nextMigrationState = Object.assign({}, migrationState); + if (!v1Complete) nextMigrationState.v1ToV2 = { + version: 1, status: 'complete', - mode: 'persistent-reconcile', - sourceChecksum: checksum(legacy), - sourceRecordCount: records.length, - skippedRecordCount: skippedRecords - }; - const currentAudit = asObject(migrationState.v1ToV2); - const comparableCurrentAudit = { - version: currentAudit.version, - status: currentAudit.status, - mode: currentAudit.mode, - sourceChecksum: currentAudit.sourceChecksum, - sourceRecordCount: currentAudit.sourceRecordCount, - skippedRecordCount: currentAudit.skippedRecordCount + completedAt: nowIso(), + sourceChecksum: checksum(indexedDb), + sourceRecordCount: asArray(indexedDb.practice_records).length }; - const externalAudit = externalBackup ? { + if (externalBackup) nextMigrationState.externalBackupV1 = { version: 1, status: 'consumed', + completedAt: nowIso(), sourceChecksum: checksum(externalBackup) - } : null; - const currentExternalAudit = asObject(migrationState.externalBackupV1); - if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) - || (externalAudit && (currentExternalAudit.status !== externalAudit.status - || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { - const nextMigrationState = Object.assign({}, migrationState, { - v1ToV2: Object.assign({}, migrationAudit, { - completedAt: nowIso(), - poisonDetected, - poisonedDocumentKeys, - libraryPoisoned - }) - }); - if (externalAudit) { - nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); - } - await kernel.mutate([{ - logicalKey: 'system.migrations', - data: nextMigrationState, - expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 - }], { - operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` - }); - } + }; + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` }); } const ready = kernel.initialize() @@ -5304,678 +4838,128 @@ /* ===== js/core/siteDataReset.js ===== */ -/** - * Destructive browser-site reset. - * - * This path deliberately bypasses AppData domain mutations. A reset must not - * append operation journals, rebuild projectors, or flush an empty snapshot to - * the bound external backup folder. - */ +/** Clear all browser-local IELTS Atlas data while preserving external JSON files. */ (function initSiteDataReset(global) { 'use strict'; if (global.SiteDataReset && global.SiteDataReset.__v2 === true) { - if (typeof global.clearCache !== 'function') { - global.clearCache = global.SiteDataReset.request; - } + global.clearCache = global.SiteDataReset.request; return; } - var DATABASE_NAMES = Object.freeze([ + const DATABASE_NAMES = Object.freeze([ 'IELTSAtlasDataV2', 'ExamSystemDB', 'IELTSAtlasExternalBackupV2' ]); - /** - * How long a `blocked` deletion is allowed to keep waiting before it is - * reported as a failure. - * - * A cooperative peer (data kernel connections install `onversionchange` and - * close immediately) releases the database within a tick, while a peer that - * is in the middle of a long write can legitimately hold it for a few - * seconds. Waiting far beyond that only makes an unrecoverable block look - * like a frozen UI, and every database is deleted in parallel, so this is - * the worst case for the whole reset rather than a per-database cost. - */ - var BLOCKED_DELETE_TIMEOUT_MS = 8000; - var EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS = 8000; - /** - * `IDBFactory.deleteDatabase()` has no `abort()`. Once the blocked timeout - * wins, the browser keeps the request armed and will drop the database the - * moment the peer connection closes — possibly minutes later, possibly after - * this page reloaded and started using a freshly created database. - * - * `pendingDeletions` is that un-cancellable tail: a database name stays here - * from the moment we give up waiting until the browser actually reports the - * request as done. While a name is listed the reset is "armed but not - * finished", which is a materially different state from both "succeeded" and - * "failed" and must be surfaced as such. - */ - var pendingDeletions = new Map(); - var pendingDeletionSequence = 0; - /** - * Cross-refresh recovery marker. - * - * A reloaded page cannot observe the previous page's `IDBRequest` — that - * object died with the old realm — so the in-memory registry above is lost on - * every reload. The marker carries the *fact* that a reset is still armed - * across the reload so the new page can tell the user the truth instead of - * looking pristine. - * - * It never re-arms a delete by itself. A later realm must obtain explicit - * recovery confirmation before it may queue a replacement deletion. - */ - var PENDING_DELETION_MARKER_KEY = 'ielts_atlas:v2:site-reset:pending-deletions'; - var WINDOW_NAME_MARKER_PREFIX = '__IELTS_ATLAS_SITE_RESET__:'; - /** - * The marker is written *after* `clearWebStorage()` (it would be wiped - * otherwise), which means it is the one key that survives a "clear - * everything" run. Age is used to strengthen the recovery warning, not to - * guess that the underlying request completed. Explicit recovery confirmation - * is the bounded escape hatch for a marker whose old realm is gone forever. - */ - var PENDING_DELETION_MARKER_TTL_MS = 600000; - var adoptedPendingDatabases = []; - var adoptedPendingMarkerState = null; - var resetPromise = null; - - function nowMs() { - try { - if (typeof Date === 'function' && typeof Date.now === 'function') return Date.now(); - } catch (_) { /* exotic host */ } - return 0; - } - - function notify(message, type) { - if (typeof global.showMessage === 'function') { - global.showMessage(message, type || 'info'); - } else if (global.console && typeof global.console.log === 'function') { - global.console.log('[SiteDataReset] ' + message); - } - } - - // Timers are looked up defensively: this module is also loaded inside test - // realms and worker-like hosts that do not expose the full window surface. - function hostSetTimeout(callback, delay) { - try { - if (global && typeof global.setTimeout === 'function') { - return { id: global.setTimeout(callback, delay), host: global }; - } - } catch (_) { /* fall through to the ambient timer */ } - if (typeof setTimeout === 'function') { - return { id: setTimeout(callback, delay), host: null }; - } - return null; - } - - function hostClearTimeout(handle) { - if (!handle) return null; - try { - if (handle.host && typeof handle.host.clearTimeout === 'function') { - handle.host.clearTimeout(handle.id); - return null; - } - } catch (_) { - return null; - } - if (typeof clearTimeout === 'function') clearTimeout(handle.id); - return null; - } - - function createBlockedError(name) { - var error = new Error( - '数据库被其他 IELTS Atlas 标签页占用,未能删除:' + name - + '(等待 ' + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒后放弃)。' - ); - error.code = 'DELETE_DATABASE_BLOCKED'; - error.blocked = true; - error.database = name; - error.timeoutMs = BLOCKED_DELETE_TIMEOUT_MS; - return error; - } - - function createQuiesceTimeoutError() { - var error = new Error( - '外部备份停止写入超时(等待 ' - + Math.round(EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS / 1000) + ' 秒)。' - ); - error.code = 'EXTERNAL_BACKUP_QUIESCE_TIMEOUT'; - error.timeoutMs = EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS; - return error; - } - - function readStorage(name) { - try { - var storage = global[name]; - if (storage && typeof storage.getItem === 'function') return storage; - } catch (_) { /* storage disabled by policy or a sandboxed frame */ } - return null; - } - - function markerStorages() { - return [readStorage('localStorage'), readStorage('sessionStorage')].filter(function (storage, index, all) { - return !!storage && all.indexOf(storage) === index; - }); - } - - function readWindowNameMarker() { - var value = ''; - try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return null; } - var parts = value.split('\n'); - for (var index = parts.length - 1; index >= 0; index -= 1) { - if (parts[index].indexOf(WINDOW_NAME_MARKER_PREFIX) === 0) { - return parts[index].slice(WINDOW_NAME_MARKER_PREFIX.length); - } - } - return null; - } + let resetPromise = null; - function replaceWindowNameMarker(raw) { - var value = ''; - try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return false; } - var retained = value.split('\n').filter(function (part) { - return part.indexOf(WINDOW_NAME_MARKER_PREFIX) !== 0; - }); - if (retained.length === 1 && retained[0] === '') retained = []; - if (raw) retained.push(WINDOW_NAME_MARKER_PREFIX + raw); - try { - global.name = retained.join('\n'); - return raw ? readWindowNameMarker() === raw : readWindowNameMarker() === null; - } catch (_) { - return false; - } - } - - /** - * Persist the recovery marker. Called only from the tail of `perform`, after - * `clearWebStorage()`, so the value is not immediately erased by the very - * reset that produced it. - */ - function writePendingDeletionMarker(names) { - if (!names || !names.length) { - clearPendingDeletionMarker(); - return true; + function notify(message, type = 'info') { + if (typeof global.showMessage === 'function') global.showMessage(message, type); + else if (global.console && typeof global.console.log === 'function') { + global.console.log(`[SiteDataReset] ${message}`); } - var value = JSON.stringify({ state: 'pending', databases: names.slice(), at: nowMs() }); - var persisted = false; - markerStorages().forEach(function (storage) { - if (typeof storage.setItem !== 'function') return; - try { - storage.setItem(PENDING_DELETION_MARKER_KEY, value); - persisted = storage.getItem(PENDING_DELETION_MARKER_KEY) === value || persisted; - } catch (_) { /* try the other storage */ } - }); - persisted = replaceWindowNameMarker(value) || persisted; - return persisted; } - function clearPendingDeletionMarker() { - markerStorages().forEach(function (storage) { - if (typeof storage.removeItem !== 'function') return; - try { - storage.removeItem(PENDING_DELETION_MARKER_KEY); - } catch (_) { /* best-effort */ } - }); - replaceWindowNameMarker(null); - } - - /** - * Read a marker left by a previous page load. - * - * Expired or malformed evidence cannot prove that the old request completed. - * Keep the page in a recoverable confirmation-required state instead of - * silently turning uncertainty into "safe". - */ - function readPendingDeletionMarker() { - var sawMarker = false; - var invalidMarker = false; - var validCandidate = null; - var rawMarkers = []; - markerStorages().forEach(function (storage) { - try { rawMarkers.push(storage.getItem(PENDING_DELETION_MARKER_KEY)); } catch (_) { /* unreadable */ } - }); - rawMarkers.push(readWindowNameMarker()); - rawMarkers.forEach(function (raw) { - if (!raw) return; - sawMarker = true; - var parsed = null; - try { parsed = JSON.parse(raw); } catch (_) { invalidMarker = true; return; } - var names = parsed && parsed.databases; - var state = parsed && parsed.state; - if (state && state !== 'pending' && state !== 'unknown') { - invalidMarker = true; - return; - } - if (!names || typeof names.length !== 'number' || !names.length) { - invalidMarker = true; + function deleteDatabase(name) { + return new Promise((resolve, reject) => { + const indexedDB = global.indexedDB; + if (!indexedDB || typeof indexedDB.deleteDatabase !== 'function') { + resolve({ name, skipped: true }); return; } - var adopted = []; - for (var index = 0; index < names.length; index += 1) { - if (DATABASE_NAMES.indexOf(names[index]) !== -1 && adopted.indexOf(names[index]) === -1) { - adopted.push(names[index]); - } - } - if (!adopted.length) { invalidMarker = true; return; } - var at = Number(parsed.at); - var age = nowMs() - (isFinite(at) ? at : 0); - validCandidate = { - databases: adopted, - state: 'unknown', - expired: !isFinite(at) || age < 0 || age > PENDING_DELETION_MARKER_TTL_MS - }; - }); - if (validCandidate) return validCandidate; - if (sawMarker || invalidMarker) { - return { databases: DATABASE_NAMES.slice(), state: 'unknown', corrupt: true }; - } - return { databases: [], state: 'retired' }; - } - - /** - * Register a deletion request we stopped waiting for, and keep watching it. - * - * The handlers installed here are intentionally *not* the ones `settle()` - * detached: those could still resolve the caller's promise and rewrite an - * outcome that has already been reported. These are pure observers — their - * only job is to notice that the un-cancellable request finally ran, so the - * pending state can be retired truthfully instead of by timeout. - */ - function trackPendingDeletion(name, request) { - pendingDeletionSequence += 1; - var token = pendingDeletionSequence; - pendingDeletions.set(name, { token: token, at: nowMs(), request: request }); - - function retire() { - var entry = pendingDeletions.get(name); - // A newer reset attempt may have replaced this entry; only the owner - // of the current token may retire it. - if (!entry || entry.token !== token) return; - pendingDeletions.delete(name); - var remaining = listLivePendingDeletions(); - if (remaining.length) { - writePendingDeletionMarker(remaining); - } else { - clearPendingDeletionMarker(); - } - } - - try { - request.onsuccess = function () { retire(); }; - request.onerror = function () { retire(); }; - // A repeated `onblocked` means the peer is still holding on. Nothing - // to retire yet, but swallow it so it cannot reach a stale handler. - request.onblocked = function () { }; - } catch (_) { - // Read-only handlers are rare, but guessing completion would be - // unsafe. The entry therefore remains restricted until this realm is - // torn down and the cross-refresh recovery flow takes over. - } - return token; - } - /** - * Live pending deletions: requests this realm issued and can still observe. - * - * Only these gate a new reset. An entry leaves this list the moment the - * browser reports the deletion done, so the common "close the other tab and - * retry" path unblocks immediately rather than waiting out a timer. - */ - function listLivePendingDeletions() { - var names = []; - pendingDeletions.forEach(function (_entry, name) { - if (names.indexOf(name) === -1) names.push(name); + let request; + try { request = indexedDB.deleteDatabase(name); } + catch (error) { reject(error); return; } + + request.onsuccess = () => resolve({ name, deleted: true }); + request.onerror = () => reject(request.error || new Error(`删除数据库失败:${name}`)); + request.onblocked = () => notify( + `数据库 ${name} 正被其他 IELTS Atlas 标签页占用。请关闭其他标签页,清理会自动继续。`, + 'warning' + ); }); - return names; } - /** Live plus adopted names — everything worth telling the user about. */ - function listPendingDeletions() { - var names = listLivePendingDeletions(); - for (var index = 0; index < adoptedPendingDatabases.length; index += 1) { - if (names.indexOf(adoptedPendingDatabases[index]) === -1) names.push(adoptedPendingDatabases[index]); + async function stopExternalBackup() { + const service = global.ExternalBackupService; + if (!service) return; + if (typeof service.prepareForFullReset === 'function') { + await service.prepareForFullReset(); + } else if (typeof service.unbindDirectory === 'function') { + await service.unbindDirectory(); } - return names; - } - - function currentDeletionState() { - if (listLivePendingDeletions().length) return 'pending'; - if (adoptedPendingDatabases.length) return 'unknown'; - return 'retired'; - } - - /** - * The reason a caller must not start a new reset right now, or null. - * - * Live requests only retire on their real terminal event. Cross-refresh - * evidence can be recovered from, but only after explicit confirmation; this - * avoids both an automatic false-safe state and a permanent marker lockout. - */ - function pendingDeletionBlock(options) { - var live = listLivePendingDeletions(); - var adopted = adoptedPendingDatabases.slice(); - if (!live.length && !adopted.length) return null; - var recoveryRequired = !live.length && adopted.length > 0 - && !(options && options.recoveryConfirmed === true); - if (!live.length && !recoveryRequired) return null; - return { - success: false, - reason: recoveryRequired ? 'recovery_confirmation_required' : 'deletion_pending', - deletionPending: true, - pendingDatabases: listPendingDeletions(), - retryable: true, - recoveryConfirmationRequired: recoveryRequired, - deletionState: currentDeletionState(), - markerExpired: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.expired), - markerCorrupt: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.corrupt), - terminal: false, - databases: DATABASE_NAMES.slice(), - externalBackupFilesPreserved: true - }; - } - - function pendingDeletionMessage(names) { - return '上一次清理仍在等待其他 IELTS Atlas 标签页关闭:' + names.join('、') - + '。浏览器无法取消这个删除请求,它会在其他标签页关闭后自动执行;' - + '在那之前请不要录入新数据,否则可能被这次迟到的删除一并清掉。'; - } - - function settleWithTimeout(value, timeoutMs, createTimeoutError) { - return new Promise(function (resolve, reject) { - var settled = false; - var timer = hostSetTimeout(function () { - if (settled) return; - settled = true; - reject(createTimeoutError()); - }, timeoutMs); - if (!timer) { - reject(createTimeoutError()); - return; - } - Promise.resolve(value).then(function (result) { - if (settled) return; - settled = true; - hostClearTimeout(timer); - resolve(result); - }, function (error) { - if (settled) return; - settled = true; - hostClearTimeout(timer); - reject(error); - }); - }); - } - - function deleteDatabaseStrict(name) { - return new Promise(function (resolve, reject) { - var indexedDb; - try { - indexedDb = global.indexedDB || null; - } catch (_) { - indexedDb = null; - } - if (!indexedDb || typeof indexedDb.deleteDatabase !== 'function') { - resolve({ name: name, skipped: true }); - return; - } - - var request; - try { - request = indexedDb.deleteDatabase(name); - } catch (error) { - reject(error); - return; - } - - var settled = false; - var blockedTimer = null; - - function settle(complete, payload) { - if (settled) return; - settled = true; - blockedTimer = hostClearTimeout(blockedTimer); - // An IndexedDB deleteDatabase request cannot be aborted. When the - // blocked timeout wins, the browser keeps the request pending and - // will still drop the database once the other tab releases its - // connection. Detaching the handlers here stops a late event from - // rewriting an outcome the caller already acted on; the request is - // then handed to `trackPendingDeletion`, whose observer handlers do - // nothing but retire the pending state when the delete really runs. - try { - request.onsuccess = null; - request.onerror = null; - request.onblocked = null; - } catch (_) { /* exotic hosts may expose read-only handlers */ } - var abandoned = !!(payload && payload.code === 'DELETE_DATABASE_BLOCKED'); - if (abandoned) trackPendingDeletion(name, request); - complete(payload); - } - - request.onsuccess = function () { - settle(resolve, { name: name, deleted: true }); - }; - request.onerror = function () { - settle(reject, request.error || new Error('删除数据库失败:' + name)); - }; - request.onblocked = function () { - if (settled || blockedTimer) return; - notify( - '清理被其他 IELTS Atlas 标签页阻塞,请立即关闭其他标签页;' - + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒内未释放将中止本次清理。', - 'warning' - ); - blockedTimer = hostSetTimeout(function () { - settle(reject, createBlockedError(name)); - }, BLOCKED_DELETE_TIMEOUT_MS); - if (!blockedTimer) { - // No timer API at all: fail fast rather than wait forever. - settle(reject, createBlockedError(name)); - } - }; - }); } function clearWebStorage() { - var failures = []; - ['localStorage', 'sessionStorage'].forEach(function (name) { - var storage; + const errors = []; + for (const name of ['localStorage', 'sessionStorage']) { try { - storage = global[name]; + const storage = global[name]; + if (storage && typeof storage.clear === 'function') storage.clear(); } catch (error) { - failures.push({ storage: name, error: error }); - return; + errors.push({ stage: 'clear-web-storage', storage: name, error }); } - if (!storage || typeof storage.clear !== 'function') return; - try { - storage.clear(); - } catch (error) { - failures.push({ storage: name, error: error }); - } - }); - return failures; + } + return errors; } - function reloadTerminal(options) { - if (options && options.reload === false) return false; - if (global.location && typeof global.location.reload === 'function') { - global.location.reload(); - return true; - } - return false; + function reload(options) { + if (options.reload === false) return false; + if (!global.location || typeof global.location.reload !== 'function') return false; + global.location.reload(); + return true; } - async function perform(options) { - var opts = options || {}; + async function perform(options = {}) { if (resetPromise) return resetPromise; - // Refuse to queue a second un-cancellable deletion behind one that is - // still armed. Checked before the singleton is installed so the refusal - // is never cached as "the" result of a reset. - var blockedByPending = pendingDeletionBlock(opts); - if (blockedByPending) { - notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); - return blockedByPending; - } - resetPromise = (async function () { - var externalBackup = global.ExternalBackupService; - var errors = []; + resetPromise = (async () => { try { - if (externalBackup && typeof externalBackup.prepareForFullReset === 'function') { - await settleWithTimeout( - externalBackup.prepareForFullReset(), - EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, - createQuiesceTimeoutError - ); - } else if (externalBackup && typeof externalBackup.unbindDirectory === 'function') { - await settleWithTimeout( - externalBackup.unbindDirectory(), - EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, - createQuiesceTimeoutError - ); - } + await stopExternalBackup(); } catch (error) { - errors.push({ stage: 'external-backup-quiesce', error: error }); - } - - var deletionResults = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabaseStrict)); - var blockedDatabases = []; - deletionResults.forEach(function (result, index) { - if (result.status !== 'rejected') return; - var reason = result.reason; - var isBlocked = !!(reason && reason.code === 'DELETE_DATABASE_BLOCKED'); - if (isBlocked) blockedDatabases.push(DATABASE_NAMES[index]); - errors.push({ - stage: isBlocked ? 'delete-database-blocked' : 'delete-database', - database: DATABASE_NAMES[index], - blocked: isBlocked, - error: reason - }); - }); - clearWebStorage().forEach(function (failure) { - errors.push({ - stage: 'clear-web-storage', - storage: failure.storage, - error: failure.error - }); - }); - - // Written after clearWebStorage() on purpose: the reset wipes every - // key, so a marker persisted any earlier would erase itself. This is - // the one key that legitimately survives a full reset, which is why - // it carries its own TTL. - // - // Adopted names are dropped unconditionally here. This run issued a - // fresh deleteDatabase() for every name, and the connection queue is - // FIFO per database: whatever a previous realm queued was necessarily - // processed ahead of the request we just awaited, so it is no longer - // outstanding regardless of how this run ended. - adoptedPendingDatabases = []; - adoptedPendingMarkerState = null; - var stillPending = listLivePendingDeletions(); - var markerPersisted = true; - if (stillPending.length) { - markerPersisted = writePendingDeletionMarker(stillPending); - if (!markerPersisted) { - errors.push({ - stage: 'pending-deletion-marker', - error: new Error('无法持久化仍在等待的数据库删除状态。') - }); - } - } else { - clearPendingDeletionMarker(); + notify('外部备份仍在写入,本次清理已取消。', 'error'); + return { + success: false, + reason: 'external_backup_busy', + terminal: false, + error, + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; } + const results = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabase)); + const errors = results.flatMap((result, index) => result.status === 'rejected' + ? [{ stage: 'delete-database', database: DATABASE_NAMES[index], error: result.reason }] + : []); + errors.push(...clearWebStorage()); if (errors.length) { - if (blockedDatabases.length) { - notify( - '清理未完成:' + blockedDatabases.join('、') - + ' 仍被其他 IELTS Atlas 标签页占用。浏览器无法取消该删除请求,' - + '它会在其他标签页关闭后自动执行。请关闭全部其他标签页(含练习/听力弹窗)后,' - + '等待当前页面确认删除完成后再重试,在此之前不要继续录入新数据。', - 'error' - ); - } else { - notify('本地数据仅部分清除,页面将刷新;请刷新后再次执行清理。', 'error'); - } - // Keep this realm alive while it owns observable delete requests. - // Reloading would discard the only truthful success/error observer. - var reloadedAfterFailure = stillPending.length ? false : reloadTerminal(opts); + notify('本地数据仅部分清除,请关闭其他标签页后重试。', 'error'); return { success: false, reason: 'partial_reset', - blocked: blockedDatabases.length > 0, - blockedDatabases: blockedDatabases, - deletionPending: stillPending.length > 0, - pendingDatabases: stillPending, - markerPersisted: markerPersisted, - deletionState: stillPending.length ? 'pending' : 'retired', - retryable: true, - // `terminal` means "this page was actually torn down". Callers - // use it to decide whether they still own a live document, so - // reporting a reload that never happened strands them on a - // page they believe is gone. - terminal: reloadedAfterFailure, - errors: errors, + terminal: false, + errors, databases: DATABASE_NAMES.slice(), externalBackupFilesPreserved: true }; } - var reloaded = reloadTerminal(opts); + return { success: true, - terminal: reloaded, - deletionState: 'retired', + terminal: reload(options), databases: DATABASE_NAMES.slice(), externalBackupFilesPreserved: true }; })(); - try { - var outcome = await resetPromise; - // The singleton exists only to collapse duplicate clicks on one - // in-flight run; it is not a result cache. Anything already settled - // must be released, or the next click replays a stale outcome without - // clearing a single byte. - // - // The one case worth keeping is a reset that really did call - // location.reload(): the document is being torn down, and holding the - // resolved promise suppresses clicks landing in that teardown window - // rather than firing a second delete against a dying realm. Reload is - // asynchronous, so those clicks are genuinely reachable. - if (!outcome || outcome.terminal !== true) resetPromise = null; - return outcome; - } catch (error) { - resetPromise = null; - throw error; - } + + try { return await resetPromise; } + finally { resetPromise = null; } } - async function request(options) { - var opts = options || {}; - // Checked before the confirm dialog: asking the user to authorise a - // destructive action we are about to refuse is worse than useless, and a - // second `deleteDatabase()` for a name that is already queued only grows - // the un-cancellable backlog. - var blockedByPending = pendingDeletionBlock(opts); - if (blockedByPending) { - if (blockedByPending.recoveryConfirmationRequired) { - var recoveryConfirmed = false; - try { - recoveryConfirmed = global.confirm( - '浏览器记录显示上一次数据库删除可能仍在等待。继续恢复会重新排队删除,' - + '请先关闭其他 IELTS Atlas 标签页;确定继续吗?' - ); - } catch (_) { recoveryConfirmed = false; } - if (recoveryConfirmed) { - opts = Object.assign({}, opts, { recoveryConfirmed: true }); - } else { - notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); - return blockedByPending; - } - } else { - notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); - return blockedByPending; - } - } - var confirmed = opts.confirmed === true; + async function request(options = {}) { + let confirmed = options.confirmed === true; if (!confirmed) { try { confirmed = global.confirm( @@ -5983,94 +4967,23 @@ + '练习记录、题库、词汇、设置、应用内备份和本地文件夹绑定都会清除;' + '外部文件夹中的 JSON 备份不会删除。' ); - } catch (_) { - confirmed = false; - } + } catch (_) { confirmed = false; } } - if (!confirmed) return { - success: false, - reason: 'cancelled', - deletionState: currentDeletionState() - }; + if (!confirmed) return { success: false, reason: 'cancelled', terminal: false }; - notify('正在清除全部本地数据…', 'info'); - try { - return await perform(opts); - } catch (error) { + notify('正在清除全部本地数据...', 'info'); + try { return await perform(options); } + catch (error) { if (global.console && typeof global.console.error === 'function') { global.console.error('[SiteDataReset] full reset failed:', error); } - notify('清除失败:' + (error && error.message ? error.message : '浏览器存储不可用'), 'error'); - return { - success: false, - reason: 'reset_failed', - deletionState: currentDeletionState(), - error: error - }; - } - } - - /** - * Adopt a marker written before the last reload and warn once. - * - * It never issues a delete. It does require explicit confirmation before a - * recovery reset, so stale evidence remains recoverable without being treated - * as proof that the late-deletion hazard disappeared. - * - * The warning is deferred because this module ships in core-foundation, - * which index.html loads *before* the ui-shell/legacy bundles that define - * `showMessage`. Warning synchronously would route the one notice the user - * actually needs into console.log instead of the message center. - */ - function adoptPendingDeletionsFromPreviousPage() { - adoptedPendingMarkerState = readPendingDeletionMarker(); - adoptedPendingDatabases = adoptedPendingMarkerState.databases; - if (!adoptedPendingDatabases.length) return; - var announced = false; - function announce() { - if (announced) return; - announced = true; - // Re-read: a reset may have completed and retired the marker while we - // were waiting for the UI layer to come up. - if (!adoptedPendingDatabases.length) return; - notify(pendingDeletionMessage(adoptedPendingDatabases), 'warning'); - } - if (typeof global.showMessage === 'function') { - announce(); - return; - } - var attempts = 0; - function poll() { - attempts += 1; - if (typeof global.showMessage === 'function' || attempts >= 20) { - announce(); - return; - } - hostSetTimeout(poll, 250); + notify(`清除失败:${error && error.message ? error.message : '浏览器存储不可用'}`, 'error'); + return { success: false, reason: 'reset_failed', terminal: false, error }; } - if (!hostSetTimeout(poll, 250)) announce(); } - global.SiteDataReset = Object.freeze({ - __v2: true, - DATABASE_NAMES: DATABASE_NAMES, - PENDING_DELETION_MARKER_KEY: PENDING_DELETION_MARKER_KEY, - deleteDatabaseStrict: deleteDatabaseStrict, - perform: perform, - request: request, - /** Names whose un-cancellable deletion has not reported back yet. */ - pendingDeletions: listPendingDeletions, - /** True while a previous deletion is still armed; see `pendingDeletions`. */ - isDeletionPending: function () { - return listPendingDeletions().length > 0; - }, - recoveryConfirmationRequired: function () { - return adoptedPendingDatabases.length > 0; - }, - deletionState: currentDeletionState - }); + global.SiteDataReset = Object.freeze({ __v2: true, DATABASE_NAMES, perform, request }); global.clearCache = request; - adoptPendingDeletionsFromPreviousPage(); })(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/bundles/legacy-app.bundle.js b/js/bundles/legacy-app.bundle.js index 352a77b9..6e665ccb 100644 --- a/js/bundles/legacy-app.bundle.js +++ b/js/bundles/legacy-app.bundle.js @@ -1776,23 +1776,7 @@ class ExamSystemApp { } }, async initializeComponents() { - const optionalComponents = []; - try { - await this.initializeCoreComponents(); - if (optionalComponents.length > 0) { - try { - await this.waitForComponents(optionalComponents, 5000); - await this.initializeOptionalComponents(); - } catch (_) { - await this.initializeAvailableOptionalComponents(); - } - } else { - await this.initializeOptionalComponents(); - } - } catch (error) { - console.error('[App] 核心组件加载失败:', error); - throw error; - } + await this.initializeCoreComponents(); }, async initializeCoreComponents() { if (this.instantiatePracticeRecorder()) { @@ -1983,7 +1967,6 @@ class ExamSystemApp { } activeSessions.set(data.examId, existing); }, - handleRealPracticeData: async () => null, savePracticeRecord: async (record) => { const receipt = await window.AppData.practice.completeAttempt({ record }); return receipt && receipt.record ? receipt.record : null; @@ -2015,43 +1998,6 @@ class ExamSystemApp { this._practiceRecorderUpgradeTimer = setInterval(tryUpgrade, interval); tryUpgrade(); }, - async initializeOptionalComponents() {}, - async initializeAvailableOptionalComponents() { - const availableComponents = [].filter((name) => window[name]); - if (availableComponents.length > 0) { - await this.initializeOptionalComponents(); - } else { - console.warn('[App] 没有发现可用的可选组件'); - } - }, - async waitForComponents(requiredClasses = ['ExamBrowser'], timeout = 3000) { - const startTime = Date.now(); - const checkInterval = 100; - while (Date.now() - startTime < timeout) { - const loadingStatus = requiredClasses.map((className) => { - const isLoaded = window[className] && typeof window[className] === 'function'; - if (!isLoaded) { - console.debug(`[App] 等待组件: ${className}`); - } - return { className, isLoaded }; - }); - const allLoaded = loadingStatus.every((status) => status.isLoaded); - if (allLoaded) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, checkInterval)); - } - const missingClasses = requiredClasses.filter((className) => !window[className] || typeof window[className] !== 'function'); - const loadedClasses = requiredClasses.filter((className) => window[className] && typeof window[className] === 'function'); - const errorMessage = [ - `组件加载超时 (${timeout}ms)`, - `已加载: ${loadedClasses.join(', ') || '无'}`, - `缺失: ${missingClasses.join(', ')}`, - '请检查组件文件是否正确加载' - ].join('\n'); - console.error('[App] 组件加载失败:', errorMessage); - throw new Error(errorMessage); - } }; const integratedFallbackMixin = { diff --git a/js/bundles/listening-record-bridge.bundle.js b/js/bundles/listening-record-bridge.bundle.js index af7fcc83..d7fbb57f 100644 --- a/js/bundles/listening-record-bridge.bundle.js +++ b/js/bundles/listening-record-bridge.bundle.js @@ -1367,6 +1367,7 @@ } return ''; } + function importedLibraryId(value, options = {}) { const id = value === null || value === undefined ? '' : String(value).trim(); if (!id && options.nullable) return null; @@ -1435,143 +1436,92 @@ }; } - function nonNegativeScalar(value) { - if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; - if (typeof value !== 'number' && typeof value !== 'string') return null; - const numeric = Number(value); - return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; - } - - function firstNonNegativeScalar(...values) { + function firstNonNegative(...values) { for (const value of values) { - const numeric = nonNegativeScalar(value); - if (numeric !== null) return numeric; + if (value === null || value === undefined || value === '' || typeof value === 'object') continue; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } return null; } - function normalizeAnswerQuestionId(value, index = 0) { - const text = value === undefined || value === null ? '' : String(value).trim(); - return text || `q${index + 1}`; - } - - function normalizeAnswerValue(value) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); - if (value && typeof value === 'object') { - if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); - if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); - if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); - return jsonValue(value, 'practice answer'); + function normalizePracticeScore(record) { + const scoreInfo = asObject(record.scoreInfo); + const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo); + const overloadedAnswers = record.correctAnswers; + if (overloadedAnswers && typeof overloadedAnswers === 'object') { + record.correctAnswerMap = Object.assign( + {}, + clone(asObject(overloadedAnswers)), + clone(asObject(record.correctAnswerMap)) + ); } - return typeof value === 'string' ? value : String(value); + const correct = firstNonNegative( + overloadedAnswers, + record.correctAnswersCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + legacyScoreInfo.correctAnswers, + legacyScoreInfo.correct + ); + if (correct !== null) record.correctAnswers = correct; + else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0; + const total = firstNonNegative( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + legacyScoreInfo.totalQuestions, + legacyScoreInfo.total + ); + if (total !== null) record.totalQuestions = total; } - function normalizeAnswerMap(value) { - const normalized = {}; - if (Array.isArray(value)) { - value.forEach((entry, index) => { - if (entry === undefined || entry === null) return; - const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; - const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); - normalized[questionId] = normalizeAnswerValue( - hasOwn(item, 'answer') ? item.answer - : hasOwn(item, 'userAnswer') ? item.userAnswer - : hasOwn(item, 'value') ? item.value - : entry - ); + function mergeAnswers(target, source) { + if (Array.isArray(source)) { + source.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1); + const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer; + if (answer !== undefined) target[questionId] = clone(answer); }); - return normalized; + return; } - if (!value || typeof value !== 'object') return normalized; - Object.entries(value).forEach(([questionId, answer], index) => { - if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; - normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); - }); - return normalized; - } - - function mergeAnswerMaps(...sources) { - const merged = {}; - for (const source of sources) { - const normalized = normalizeAnswerMap(source); - for (const [questionId, answer] of Object.entries(normalized)) { - if (!hasOwn(merged, questionId)) merged[questionId] = answer; - } + for (const [questionId, answer] of Object.entries(asObject(source))) { + target[String(questionId)] = clone(answer); } - return merged; } - function canonicalizeAnswerSource(record) { - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const rawRealData = asObject(rawData.realData); - record.answers = mergeAnswerMaps( - record.answers, - record.answerMap, - record.answerList, - realData.answers, - realData.answerMap, - rawData.answers, - rawData.answerMap, - rawRealData.answers - ); - // These names remain accepted only at the compatibility boundary. The - // canonical detail layer owns one user-answer source: `answers`. - delete record.answerMap; - delete record.answerList; + function normalizePracticeAnswers(record) { + const answers = {}; + const raw = asObject(record.rawData); + const rawReal = asObject(raw.realData); + const real = asObject(record.realData); + for (const source of [ + rawReal.answerMap, rawReal.answerList, rawReal.answers, + raw.answerMap, raw.answerList, raw.answers, + real.answerMap, real.answerList, real.answers, + record.answerMap, record.answerList, record.answers + ]) mergeAnswers(answers, source); + if (Object.keys(answers).length) record.answers = answers; } - function normalizePracticeScoreFields(record) { - const scoreInfo = asObject(record.scoreInfo); - const realScoreInfo = asObject(asObject(record.realData).scoreInfo); - const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); - const overloadedCorrectAnswers = record.correctAnswers; - const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; - if (isAnswerMap) { - const existingMap = record.correctAnswerMap; - const overloadedObject = asObject(overloadedCorrectAnswers); - const existingObject = asObject(existingMap); - if (Object.keys(overloadedObject).length) { - record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); - } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { - record.correctAnswerMap = clone(overloadedCorrectAnswers); - } - } - - let correctAnswers = firstNonNegativeScalar( - overloadedCorrectAnswers, - record.correctAnswersCount, - record.correctCount, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct, - rawScoreInfo.correctAnswers, - rawScoreInfo.correct - ); - if (correctAnswers === null) { - const comparisons = asArray(record.answerComparison).length - ? asArray(record.answerComparison) - : asArray(asObject(record.realData).answerComparison); - if (comparisons.length) { - correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; - } + function questionTypeErrorCounts(source) { + const counts = {}; + const add = (type, count = 1) => { + const key = String(type || '').trim(); + if (key && count > 0) counts[key] = (counts[key] || 0) + count; + }; + for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) { + const metrics = asObject(value); + const total = firstNonNegative(metrics.totalQuestions, metrics.total); + const correct = firstNonNegative(metrics.correctAnswers, metrics.correct); + if (total !== null && correct !== null) add(type, Math.max(0, total - correct)); } - if (correctAnswers !== null) record.correctAnswers = correctAnswers; - else if (isAnswerMap) record.correctAnswers = 0; - - const totalQuestions = firstNonNegativeScalar( - record.totalQuestions, - record.questionCount, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total, - rawScoreInfo.totalQuestions, - rawScoreInfo.total - ); - if (totalQuestions !== null) record.totalQuestions = totalQuestions; + for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) { + if (detail && detail.isCorrect === false) add(detail.questionType || detail.type); + } + return counts; } function canonicalizeRecord(input) { @@ -1585,8 +1535,8 @@ record.metadata = asObject(record.metadata); if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; - canonicalizeAnswerSource(record); - normalizePracticeScoreFields(record); + normalizePracticeAnswers(record); + normalizePracticeScore(record); for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { if (record[field] === undefined || record[field] === null || record[field] === '') continue; const numeric = Number(record[field]); @@ -1597,97 +1547,13 @@ return jsonValue(record, 'canonical practice record'); } - function deriveQuestionTypeErrorCounts(source) { - const record = asObject(source); - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const counts = {}; - const addCount = (type, value) => { - const key = String(type || 'other').trim() || 'other'; - const amount = Math.max(0, Number(value) || 0); - if (amount > 0) counts[key] = (counts[key] || 0) + amount; - }; - const performanceSources = [ - record.questionTypePerformance, - realData.questionTypePerformance, - rawData.questionTypePerformance - ]; - let hasPerformanceData = false; - for (const performanceMap of performanceSources) { - if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; - let sourceHasPerformanceData = false; - for (const [type, value] of Object.entries(performanceMap)) { - const performance = asObject(value); - const total = Number(performance.total ?? performance.totalQuestions); - const correct = Number(performance.correct ?? performance.correctAnswers); - if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; - hasPerformanceData = true; - sourceHasPerformanceData = true; - addCount(type, total - correct); - } - if (sourceHasPerformanceData) break; - } - if (hasPerformanceData) return counts; - - const questionTypeMap = Object.assign( - {}, - asObject(rawData.questionTypeMap), - asObject(realData.questionTypeMap), - asObject(record.questionTypeMap) - ); - const normalizedTypeMap = {}; - for (const [questionId, type] of Object.entries(questionTypeMap)) { - normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; - } - const detailSources = [ - record.answerDetails, - asObject(record.scoreInfo).details, - realData.answerDetails, - asObject(realData.scoreInfo).details, - rawData.answerDetails, - asObject(rawData.scoreInfo).details - ]; - const seenQuestions = new Set(); - for (const details of detailSources) { - if (!details || typeof details !== 'object' || Array.isArray(details)) continue; - for (const [questionId, value] of Object.entries(details)) { - const detail = asObject(value); - const normalizedId = String(questionId).trim().toLowerCase(); - if (!normalizedId || seenQuestions.has(normalizedId)) continue; - let isWrong = detail.isCorrect === false || detail.correct === false; - if (detail.isCorrect === true || detail.correct === true) isWrong = false; - else if (!isWrong) { - const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); - const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); - isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); - } - if (!isWrong) continue; - seenQuestions.add(normalizedId); - addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); - } - } - return counts; - } - function lightSuiteEntry(source, fallbackType = null) { const entry = asObject(source); const scoreInfo = asObject(entry.scoreInfo); const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); const metadata = asObject(entry.metadata); - const totalQuestions = firstNonNegativeScalar( - entry.totalQuestions, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total - ) ?? 0; - const correctAnswers = firstNonNegativeScalar( - entry.correctAnswers, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct - ) ?? 0; + const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0; + const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0; const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; const accuracy = normalizeAccuracyRatio( explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), @@ -1699,14 +1565,14 @@ sessionId: entry.sessionId || null, examId: entry.examId || metadata.examId || null, title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', - type: entry.type || metadata.type || metadata.examType || fallbackType || null, + type: entry.type || metadata.type || fallbackType, date: entry.date || entry.completedAt || entry.timestamp || null, duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, totalQuestions, correctAnswers, accuracy, percentage, - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + questionTypeErrorCounts: questionTypeErrorCounts(entry) }, 'suite entry light projection'); } @@ -1743,6 +1609,7 @@ accuracy, percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + questionTypeErrorCounts: questionTypeErrorCounts(source), // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 @@ -1756,8 +1623,10 @@ 'dataSource', 'source', 'libraryConfigurationId' ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), suite: source.suite == null ? null : clone(asObject(source.suite)), - suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry( + entry, + String(source.type || '').replace(/-suite$/, '') || null + )) }, 'practice light projection'); } @@ -1778,7 +1647,7 @@ return first === undefined ? {} : clone(first); } - const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']); const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); function withoutRawData(value) { @@ -1797,11 +1666,10 @@ const detail = { recordId: source.id }; const annotations = { recordId: source.id }; for (const [key, value] of Object.entries(source)) { - if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue; if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { const next = Object.assign({}, asObject(entry)); - canonicalizeAnswerSource(next); const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); @@ -1823,7 +1691,7 @@ } // Accept the old mirror only as an input normalization boundary; it is never persisted. const realData = asObject(source.realData); const rawData = asObject(source.rawData); - for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); } for (const key of ANNOTATION_FIELDS) { @@ -2036,8 +1904,6 @@ // Entity records are authoritative. Projections are assembled on reads, never cached or // scheduled as follow-up work; this keeps a successful write immediately observable. - async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } - async function retryMergeConflict(options, task, maxAttempts = 3) { const explicitRevision = hasOwn(options, 'expectedRevision'); let lastError; @@ -2137,51 +2003,10 @@ function practiceLayerId(row) { return String(row && (row.recordId || row.id || row.sessionId) || ''); } - async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { - // The real kernel reads all three entity stores in one readonly - // IndexedDB transaction. Keep the fallback for deliberately minimal - // embedders and unit-test kernels that only expose the original methods. - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); - } - const ids = recordIds === null || recordIds === undefined - ? null - : (Array.isArray(recordIds) ? recordIds : [recordIds]) - .map((value) => String(value || '')) - .filter(Boolean); - const summaries = ids === null - ? await kernel.listEntities('practiceSummaries', { withMeta }) - : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); - const targetIds = summaries.map(practiceLayerId).filter(Boolean); - const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; - const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) - .then((rows) => rows.filter(Boolean)); - return { - practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], - practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], - practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] - }; - } async function practiceLayers(recordId, withMeta = false) { - const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta }); const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; - return { - summary: find('practiceSummaries'), - detail: find('practiceDetails'), - annotations: find('practiceAnnotations') - }; - } - async function suiteChildRecordIds(command, aggregateRecordId) { - const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); - const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); - if (sessionIds.size) { - const summaries = await kernel.listEntities('practiceSummaries'); - for (const summary of summaries) { - if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); - } - } - ids.delete(String(aggregateRecordId)); - return ids; + return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') }; } function entityRevision(row) { return row ? Number(row.revision) : 0; } function practiceUpserts(recordId, layers, existing = {}) { @@ -2193,76 +2018,30 @@ } async function joinedPractice(recordId, projection, snapshot = null) { const mode = String(projection || 'full').toLowerCase(); - const layers = snapshot || await practiceProjectionSnapshot([recordId], false, - mode === 'light' || mode === 'summary' - ? ['practiceSummaries'] - : (mode === 'detail' || mode === 'medium' - ? ['practiceSummaries', 'practiceDetails'] - : null)); - const summary = asArray(layers.practiceSummaries) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const stores = mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined); + const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores }); + const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + const summary = find('practiceSummaries'); if (!summary) return null; if (mode === 'light' || mode === 'summary') return clone(summary); - const detail = asArray(layers.practiceDetails) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const detail = find('practiceDetails'); if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); - const annotations = asArray(layers.practiceAnnotations) - .find((row) => practiceLayerId(row) === String(recordId)) || null; - return joinPracticeRecord(summary, detail, annotations, mode); - } - function practiceSummaryTime(summary) { - const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); - return Number.isFinite(time) ? time : 0; - } - function isReadingInsightSummary(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => practiceType(entry) === 'reading'); - } - return practiceType(asObject(summary)) === 'reading'; - } - function needsQuestionTypeInsightBackfill(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => - practiceType(entry) === 'reading' - && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); - } - return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode); } const practice = Object.freeze({ async list(options = {}) { await ready; const projection = String(options.projection || 'full').toLowerCase(); - const snapshot = await practiceProjectionSnapshot(null, false, - projection === 'light' || projection === 'summary' - ? ['practiceSummaries'] - : null); - const summaries = asArray(snapshot.practiceSummaries); + const summaries = await kernel.listEntities('practiceSummaries'); if (projection === 'light' || projection === 'summary') return summaries; - return (await Promise.all(summaries - .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) - .filter(Boolean); - }, - async listInsights(options = {}) { - await ready; - const requestedLimit = Number(options.limit); - const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 - ? Math.min(requestedLimit, 50) - : 10; - const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); - const summaries = asArray(snapshot.practiceSummaries) - .filter(isReadingInsightSummary) - .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) - .slice(0, limit); - return summaries.map((summary) => { - if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); - const detail = asArray(snapshot.practiceDetails) - .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; - return detail - ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) - : clone(summary); - }); + const stores = projection === 'detail' || projection === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : undefined; + const snapshot = await kernel.readPracticeSnapshot(null, { stores }); + return (await Promise.all(asArray(snapshot.practiceSummaries) + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean); }, async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, async completeAttempt(command) { @@ -2282,9 +2061,13 @@ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String); + const children = new Set((await kernel.listEntities('practiceSummaries')) + .filter((summary) => practiceRecordMatches(summary, childIdentities)) + .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId']))); + children.delete(recordId); const receipt = await retryMergeConflict(command, async () => { const existing = await practiceLayers(recordId, true); - const children = await suiteChildRecordIds(command, recordId); const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); }); @@ -2327,6 +2110,22 @@ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); }, async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async listInsights(options = {}) { + await ready; + const limit = Math.max(1, Math.min(50, Number(options.limit) || 10)); + const summaries = (await kernel.listEntities('practiceSummaries')) + .slice() + .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '') + .localeCompare(String(left.date || left.completedAt || left.timestamp || ''))) + .slice(0, limit); + return Promise.all(summaries.map(async (summary) => { + if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary); + const detail = await kernel.readEntity('practiceDetails', summary.id); + return jsonValue(Object.assign({}, clone(summary), { + questionTypeErrorCounts: questionTypeErrorCounts(detail) + }), 'practice insight'); + })); + }, async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, projectLight, projectDetail @@ -2574,88 +2373,6 @@ 'library.activeConfigurationId' ]); - function parseLegacyImportValue(value) { - if (typeof internals.parseLegacyValue !== 'function') { - throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); - } - return internals.parseLegacyValue(value); - } - - function decodePoisonedDocument(logicalKey, wrapped) { - const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; - if (!aliases || !isPlainImportObject(wrapped) - || !Object.prototype.hasOwnProperty.call(wrapped, 'key') - || !Object.prototype.hasOwnProperty.call(wrapped, 'value') - || !aliases.includes(String(wrapped.key))) { - return { matched: false, value: null }; - } - const decoded = parseLegacyImportValue(wrapped.value); - if (!isPlainImportObject(decoded)) return { matched: true, value: null }; - const overlay = {}; - for (const [key, value] of Object.entries(wrapped)) { - if (key === 'key' || key === 'value' || key === 'timestamp') continue; - overlay[key] = clone(value); - } - return { - matched: true, - value: Object.assign({}, decoded, overlay) - }; - } - - function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { - if (!envelope || envelope.state !== 'present') return envelope; - const wrapped = envelope.data; - const decoded = decodePoisonedDocument(logicalKey, wrapped); - if (!decoded.matched || !decoded.value) return envelope; - const entry = catalog.get(logicalKey); - const next = internals.makeEnvelope(entry, decoded.value, { - revision: Number(envelope.revision) || 1, - operationId: String(envelope.operationId || randomId('import-repair')), - updatedAt: envelope.updatedAt - }); - repairedKeys.push(logicalKey); - warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); - return next; - } - - function validateLibraryImportBundle(envelopes) { - const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); - if (!presentKeys.length) return { valid: true, presentKeys }; - if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { - return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; - } - const values = {}; - for (const key of LIBRARY_IMPORT_KEYS) { - const envelope = envelopes[key]; - values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; - } - const configurations = asArray(values['library.configurations']); - const indexes = asObject(values['library.importedIndexes']); - const configurationIds = new Set(); - for (const configuration of configurations) { - const source = asObject(configuration); - const id = idOf(source, ['id', 'key', 'configId']); - if (!id || !acceptedLibraryId(id) || source.builtIn === true - || !Array.isArray(indexes[id]) || !indexes[id].length) { - return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; - } - configurationIds.add(id); - } - for (const [id, index] of Object.entries(indexes)) { - if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { - return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; - } - } - const activeId = values['library.activeConfigurationId']; - if (activeId !== null && (!acceptedLibraryId(activeId) - || !configurationIds.has(String(activeId)) - || !Array.isArray(indexes[String(activeId)]) - || !indexes[String(activeId)].length)) { - return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; - } - return { valid: true, presentKeys }; - } - function canonicalizeV2Import(parsed) { const warnings = []; const repairedKeys = []; @@ -2663,41 +2380,43 @@ const envelopes = {}; for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); - if (logicalKey === 'library.activeConfigurationId' - && rawEnvelope && rawEnvelope.state === 'present' - && String(rawEnvelope.data) === '[object Object]') { + const envelope = clone(rawEnvelope); + const data = envelope && envelope.state === 'present' ? envelope.data : null; + if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') { ignoredKeys.push(logicalKey); warnings.push('Skipped poisoned active library id'); continue; } - const repairCount = repairedKeys.length; - const repaired = repairPoisonedImportEnvelope( - logicalKey, - clone(rawEnvelope), - repairedKeys, - warnings - ); - const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; - const isLegacyRowWrapper = isPlainImportObject(rawData) - && Object.prototype.hasOwnProperty.call(rawData, 'key') - && Object.prototype.hasOwnProperty.call(rawData, 'value') - && String(rawData.key || '').startsWith('exam_system_'); - if (isLegacyRowWrapper && repairedKeys.length === repairCount) { - ignoredKeys.push(logicalKey); - warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); - continue; - } - envelopes[logicalKey] = repaired; - } - const library = parsed.scope === 'full' - ? validateLibraryImportBundle(envelopes) - : { valid: true, presentKeys: [] }; - if (!library.valid) { - for (const logicalKey of library.presentKeys) { - delete envelopes[logicalKey]; - ignoredKeys.push(logicalKey); + if (isPlainImportObject(data) + && Object.prototype.hasOwnProperty.call(data, 'key') + && Object.prototype.hasOwnProperty.call(data, 'value') + && String(data.key || '').startsWith('exam_system_')) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || []; + const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null; + if (!isPlainImportObject(decoded)) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + const overlay = Object.fromEntries(Object.entries(data) + .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp')); + envelope.data = Object.assign({}, decoded, overlay); + envelope.checksum = checksum(envelope.data); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + } + envelopes[logicalKey] = envelope; + } + + if (parsed.scope === 'full') { + const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) { + for (const key of presentLibraryKeys) { + delete envelopes[key]; + ignoredKeys.push(key); + } + warnings.push('Skipped incomplete library data'); } - warnings.push(`Skipped unsafe library data: ${library.reason}`); } const exportableKeys = catalog.list() .filter((entry) => entry.export === true && isImportableEntry(entry)) @@ -2705,9 +2424,7 @@ const missingKeys = parsed.scope === 'full' ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) : []; - if (parsed.scope === 'full' && missingKeys.length) { - warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); - } + const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length); return { envelopes, warnings, @@ -2715,10 +2432,8 @@ ignoredKeys, missingKeys, declaredScope: parsed.scope, - effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, - trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length - ? 'trusted-full' - : 'degraded-partial' + effectiveScope: degraded ? 'partial' : parsed.scope, + trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial') }; } @@ -2979,9 +2694,6 @@ } async function currentEntitySnapshot() { - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(null, { withMeta: true }); - } const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); const result = {}; for (const store of PRACTICE_ENTITY_STORES) { @@ -3018,19 +2730,31 @@ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); continue; } - const currentRead = await kernel.read(logicalKey, { withMeta: true }); - const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; - const currentEnvelope = currentRead && currentRead.envelope; - revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + const current = await kernel.read(logicalKey, { withMeta: true }); + revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0; let next = envelope; if (!replaceDocuments && envelope.state === 'present') { - next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') }); } snapshot.envelopes[logicalKey] = next; keys.push(logicalKey); if (next.state === 'cleared') clearedKeys.push(logicalKey); } + // A full replace mirrors all exportable user data. Missing physical + // envelopes mean catalog defaults, represented here as explicit clears. + if (replaceDocuments && parsed.scope === 'full') { + for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) { + if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue; + snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, { + state: 'cleared', + operationId: randomId('import-clear') + }); + keys.push(entry.logicalKey); + clearedKeys.push(entry.logicalKey); + } + } + // Any successful practice import installs all three stores together. Merge // may update a subset only when the final recordId sets remain identical. const sourceStores = Object.keys(asObject(parsed.entities)); @@ -3243,7 +2967,7 @@ const mutation = optionsMutationOptions(options, 'vocab-words', words); return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.words', { withMeta: true }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.words', data: words, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3285,7 +3009,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3300,7 +3024,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), upserts); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3327,7 +3051,7 @@ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); list.updatedAt = nowIso(); collections[id] = list; - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3345,7 +3069,7 @@ const current = await kernel.read('vocab.lists', { withMeta: true }); const collections = Object.assign({}, asObject(current.data)); collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3411,7 +3135,7 @@ { id: listId, words: merged, updatedAt: nowIso() } ) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3444,7 +3168,7 @@ : Object.assign({}, collections, { [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) @@ -3471,7 +3195,7 @@ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - return mutateAndProject(changes, mutation); + return kernel.mutate(changes, mutation); }); } }); @@ -3606,253 +3330,95 @@ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], 'achievements.manual': ['achievement_manual_state', 'user_achievements'] }); - const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ - 'recovery.activeSessions', - 'recovery.drafts', - 'recovery.interrupted', - 'recovery.rejectedCompletions' - ]); const LEGACY_PREFERENCE_ALIASES = Object.freeze({ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' }); - function legacyRecordArray(value) { - return Array.isArray(value) ? value : asArray(asObject(value).data); - } - function mergeLegacyExternalBackup(legacyValue, externalValue) { - const legacy = Object.assign({}, asObject(legacyValue)); + function mergeLegacySources(indexedDbValue, externalValue) { + const indexedDb = asObject(indexedDbValue); const external = asObject(externalValue); - const externalRecordKey = ['practice_records', 'practiceRecords'] - .find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (externalRecordKey) { - const records = new Map(); - for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { - const recordId = idOf(record, ['id', 'recordId', 'sessionId']); - records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); - } - legacy.practice_records = Array.from(records.values()); - } - for (const [target, aliases] of Object.entries({ - user_stats: ['user_stats', 'userStats'], - exam_index: ['exam_index', 'examIndex'], - storage_version: ['storage_version', 'storageVersion'] - })) { - if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; - const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (alias) legacy[target] = clone(external[alias]); - } - return legacy; - } - - function acceptedLibraryId(value) { - const id = value === null || value === undefined ? '' : String(value).trim(); - if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; - try { return importedLibraryId(id); } catch (_) { return null; } - } - - function remapLegacyLibraryId(value) { - return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + const merged = Object.assign({}, external, indexedDb); + const records = new Map(); + const addRecords = (value) => { + const list = Array.isArray(value) ? value : asArray(asObject(value).data); + list.forEach((record) => { + const id = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record)); + }); + }; + addRecords(external.practice_records || external.practiceRecords); + addRecords(indexedDb.practice_records); + if (records.size) merged.practice_records = Array.from(records.values()); + return merged; } - async function migrateLegacyLibraryData(legacy) { - const [configMeta, indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.configurations', { withMeta: true }), - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const legacyIdMap = new Map(); + function legacyLibraryBundle(legacy) { + const idMap = new Map(); const indexes = {}; - const addLegacyIndex = (oldId, value) => { - if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; - const index = asArray(value); - if (!index.length) return; - const mappedId = remapLegacyLibraryId(oldId); - legacyIdMap.set(oldId, mappedId); - indexes[mappedId] = clone(index); - }; - - for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); - // Reconciliation is a union. A healthy current v2 value wins for the same - // deterministic library ID, while missing legacy libraries are restored. - for (const [id, value] of Object.entries(asObject(indexMeta.data))) { - if (/^exam_index_/.test(id)) addLegacyIndex(id, value); - else { - const acceptedId = acceptedLibraryId(id); - if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); - } + for (const [oldId, value] of Object.entries(asObject(legacy))) { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue; + const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`; + idMap.set(oldId, id); + indexes[id] = clone(value); } - + if (!idMap.size) return null; const configurations = new Map(); - const addConfiguration = (configuration) => { - const source = asObject(configuration); - const oldId = idOf(source, ['id', 'key', 'configId']); - if (!oldId || oldId === 'exam_index') return; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - if (!id || !asArray(indexes[id]).length) return; - configurations.set(id, Object.assign({}, clone(source), { + asArray(legacy.exam_index_configurations).forEach((configuration) => { + const oldId = idOf(configuration, ['id', 'key', 'configId']); + const id = idMap.get(oldId); + if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length })); + }); + for (const [oldId, id] of idMap) { + if (!configurations.has(id)) configurations.set(id, { id, key: id, - examCount: indexes[id].length - })); - }; - asArray(legacy.exam_index_configurations).forEach(addConfiguration); - asArray(configMeta.data).forEach(addConfiguration); - for (const [oldId, id] of legacyIdMap) { - if (!configurations.has(id)) { - configurations.set(id, { - id, - key: id, - name: `迁移的自定义题库 (${oldId})`, - examCount: indexes[id].length, - sourceType: 'legacy-import' - }); - } - } - - const resolveActive = (value) => { - const oldId = value === null || value === undefined ? '' : String(value).trim(); - if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - return id && asArray(indexes[id]).length ? id : null; - }; - const currentRawActive = activeMeta.data; - let activeId = resolveActive(currentRawActive); - const currentIsExplicitDefault = Boolean(activeMeta.envelope) - && (currentRawActive === null || String(currentRawActive || '').trim() === ''); - if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { - activeId = resolveActive(legacy.active_exam_index_key); - } - - const nextConfigurations = Array.from(configurations.values()); - const changes = []; - if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { - changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); - } - if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { - changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); - } - if (activeId !== activeMeta.data) { - changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); - } - if (changes.length) { - await kernel.mutate(changes, { - operationId: `legacy-library-repair-v2-${checksum(changes)}` + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' }); } + return { + configurations: Array.from(configurations.values()), + indexes, + activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null + }; } async function migrateLegacyData() { // Unit embedders may provide a deliberately minimal kernel bootstrap. if (typeof internals.readLegacyValues !== 'function') return; - const legacySource = await internals.readLegacyValues(); - if (legacySource && legacySource.__legacyReadComplete === false) { - throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); - } const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); const migrationState = asObject(migrationMeta.data); + const v1Complete = asObject(migrationState.v1ToV2).status === 'complete'; + const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed'; let externalBackup = null; - if (asObject(migrationState.externalBackupV1).status !== 'consumed' - && typeof internals.readLegacyExternalBackup === 'function') { - try { - externalBackup = await internals.readLegacyExternalBackup(); - } catch (error) { + if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') { + try { externalBackup = await internals.readLegacyExternalBackup(); } + catch (error) { if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } - const legacy = externalBackup - ? mergeLegacyExternalBackup(legacySource, externalBackup) - : legacySource; - if (!legacy || !Object.keys(legacy).length) return; - - const documentMetas = {}; - for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { - documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); - } - const [indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const documentRepairs = []; - const poisonedDocumentKeys = []; - for (const [logicalKey, current] of Object.entries(documentMetas)) { - if (!current.envelope || current.envelope.state !== 'present') continue; - const decoded = decodePoisonedDocument(logicalKey, current.data); - if (!decoded.matched) continue; - poisonedDocumentKeys.push(logicalKey); - const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; - const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - const legacyValue = legacyAlias ? legacy[legacyAlias] : null; - let repairValue = decoded.value; - if (isPlainImportObject(legacyValue)) { - repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); - } - if (!repairValue) continue; - documentRepairs.push({ - logicalKey, - data: repairValue, - expectedRevision: Number(current.envelope.revision) - }); - } - const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => - isPlainImportObject(value) - && Object.prototype.hasOwnProperty.call(value, 'key') - && Object.prototype.hasOwnProperty.call(value, 'value') - && /^exam_system_exam_index_/.test(String(value.key || ''))); - const poisonedActive = String(activeMeta.data) === '[object Object]'; - const libraryPoisoned = poisonedIndex || poisonedActive; - const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; - - await migrateLegacyLibraryData(legacy); - if (documentRepairs.length) { - await kernel.mutate(documentRepairs, { - operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` - }); - } + if (v1Complete && !externalBackup) return; + const indexedDb = await internals.readLegacyValues(); + if (indexedDb && indexedDb.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const legacy = mergeLegacySources(indexedDb, externalBackup); const changes = []; for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { - const currentAudit = asObject(migrationState.v1ToV2); - if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { - continue; - } const current = await kernel.getEnvelope(logicalKey); + if (current) continue; const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - if (!alias) continue; - const legacyValue = legacy[alias]; - if (!current) { - changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); - continue; - } - const isBadMigrationWrite = current.state === 'present' - && /^legacy-documents-/.test(String(current.operationId || '')); - if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { - changes.push({ - logicalKey, - data: legacyValue, - expectedRevision: Number(current.revision) - }); - continue; - } - const entry = catalog.get(logicalKey); - if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; - let merged; - try { - merged = mergeImportValue(entry, legacyValue, current.data); - } catch (error) { - if (global.console && console.warn) { - console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); - } - continue; - } - if (checksum(merged) !== checksum(current.data)) { - changes.push({ - logicalKey, - data: merged, - expectedRevision: Number(current.revision) - }); - } + if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 }); + } + const libraryBundle = legacyLibraryBundle(legacy); + if (libraryBundle) { + if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 }); } if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { const preferences = {}; @@ -3867,84 +3433,52 @@ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); } - if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` }); const recordsValue = legacy.practice_records; const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); const operations = []; - let skippedRecords = 0; - const reconciledRecordIds = new Set(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; - let canonical; - let parts; + for (const [index, record] of records.entries()) { try { - const candidate = jsonValue(record, 'legacy practice record'); - if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { - candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const candidate = clone(record); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const canonical = canonicalizeRecord(candidate); + const parts = splitPracticeRecord(canonical); + for (const [store, data] of [ + ['practiceSummaries', parts.summary], + ['practiceDetails', parts.detail], + ['practiceAnnotations', parts.annotations] + ]) { + if (!await kernel.readEntity(store, canonical.id)) { + operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 }); + } } - canonical = canonicalizeRecord(candidate); - parts = splitPracticeRecord(canonical); } catch (error) { - skippedRecords += 1; if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); - continue; } - if (reconciledRecordIds.has(canonical.id)) continue; - reconciledRecordIds.add(canonical.id); - // Storage errors are not malformed records. Let them abort this repair so the - // completion marker is not written and the next startup can retry safely. - const existing = await practiceLayers(canonical.id, true); - if (existing.summary && existing.detail && existing.annotations) continue; - operations.push(...practiceUpserts(canonical.id, parts, existing)); } if (operations.length) { - await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` }); } - const migrationAudit = { - version: 4, + + const nextMigrationState = Object.assign({}, migrationState); + if (!v1Complete) nextMigrationState.v1ToV2 = { + version: 1, status: 'complete', - mode: 'persistent-reconcile', - sourceChecksum: checksum(legacy), - sourceRecordCount: records.length, - skippedRecordCount: skippedRecords - }; - const currentAudit = asObject(migrationState.v1ToV2); - const comparableCurrentAudit = { - version: currentAudit.version, - status: currentAudit.status, - mode: currentAudit.mode, - sourceChecksum: currentAudit.sourceChecksum, - sourceRecordCount: currentAudit.sourceRecordCount, - skippedRecordCount: currentAudit.skippedRecordCount + completedAt: nowIso(), + sourceChecksum: checksum(indexedDb), + sourceRecordCount: asArray(indexedDb.practice_records).length }; - const externalAudit = externalBackup ? { + if (externalBackup) nextMigrationState.externalBackupV1 = { version: 1, status: 'consumed', + completedAt: nowIso(), sourceChecksum: checksum(externalBackup) - } : null; - const currentExternalAudit = asObject(migrationState.externalBackupV1); - if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) - || (externalAudit && (currentExternalAudit.status !== externalAudit.status - || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { - const nextMigrationState = Object.assign({}, migrationState, { - v1ToV2: Object.assign({}, migrationAudit, { - completedAt: nowIso(), - poisonDetected, - poisonedDocumentKeys, - libraryPoisoned - }) - }); - if (externalAudit) { - nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); - } - await kernel.mutate([{ - logicalKey: 'system.migrations', - data: nextMigrationState, - expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 - }], { - operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` - }); - } + }; + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` }); } const ready = kernel.initialize() diff --git a/js/bundles/listening-wrapper.bundle.js b/js/bundles/listening-wrapper.bundle.js index f9e25ec4..2f822a12 100644 --- a/js/bundles/listening-wrapper.bundle.js +++ b/js/bundles/listening-wrapper.bundle.js @@ -1367,6 +1367,7 @@ } return ''; } + function importedLibraryId(value, options = {}) { const id = value === null || value === undefined ? '' : String(value).trim(); if (!id && options.nullable) return null; @@ -1435,143 +1436,92 @@ }; } - function nonNegativeScalar(value) { - if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; - if (typeof value !== 'number' && typeof value !== 'string') return null; - const numeric = Number(value); - return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; - } - - function firstNonNegativeScalar(...values) { + function firstNonNegative(...values) { for (const value of values) { - const numeric = nonNegativeScalar(value); - if (numeric !== null) return numeric; + if (value === null || value === undefined || value === '' || typeof value === 'object') continue; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } return null; } - function normalizeAnswerQuestionId(value, index = 0) { - const text = value === undefined || value === null ? '' : String(value).trim(); - return text || `q${index + 1}`; - } - - function normalizeAnswerValue(value) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); - if (value && typeof value === 'object') { - if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); - if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); - if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); - return jsonValue(value, 'practice answer'); - } - return typeof value === 'string' ? value : String(value); - } - - function normalizeAnswerMap(value) { - const normalized = {}; - if (Array.isArray(value)) { - value.forEach((entry, index) => { - if (entry === undefined || entry === null) return; - const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; - const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); - normalized[questionId] = normalizeAnswerValue( - hasOwn(item, 'answer') ? item.answer - : hasOwn(item, 'userAnswer') ? item.userAnswer - : hasOwn(item, 'value') ? item.value - : entry - ); - }); - return normalized; - } - if (!value || typeof value !== 'object') return normalized; - Object.entries(value).forEach(([questionId, answer], index) => { - if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; - normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); - }); - return normalized; - } - - function mergeAnswerMaps(...sources) { - const merged = {}; - for (const source of sources) { - const normalized = normalizeAnswerMap(source); - for (const [questionId, answer] of Object.entries(normalized)) { - if (!hasOwn(merged, questionId)) merged[questionId] = answer; - } - } - return merged; - } - - function canonicalizeAnswerSource(record) { - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const rawRealData = asObject(rawData.realData); - record.answers = mergeAnswerMaps( - record.answers, - record.answerMap, - record.answerList, - realData.answers, - realData.answerMap, - rawData.answers, - rawData.answerMap, - rawRealData.answers - ); - // These names remain accepted only at the compatibility boundary. The - // canonical detail layer owns one user-answer source: `answers`. - delete record.answerMap; - delete record.answerList; - } - - function normalizePracticeScoreFields(record) { + function normalizePracticeScore(record) { const scoreInfo = asObject(record.scoreInfo); - const realScoreInfo = asObject(asObject(record.realData).scoreInfo); - const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); - const overloadedCorrectAnswers = record.correctAnswers; - const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; - if (isAnswerMap) { - const existingMap = record.correctAnswerMap; - const overloadedObject = asObject(overloadedCorrectAnswers); - const existingObject = asObject(existingMap); - if (Object.keys(overloadedObject).length) { - record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); - } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { - record.correctAnswerMap = clone(overloadedCorrectAnswers); - } + const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo); + const overloadedAnswers = record.correctAnswers; + if (overloadedAnswers && typeof overloadedAnswers === 'object') { + record.correctAnswerMap = Object.assign( + {}, + clone(asObject(overloadedAnswers)), + clone(asObject(record.correctAnswerMap)) + ); } - - let correctAnswers = firstNonNegativeScalar( - overloadedCorrectAnswers, + const correct = firstNonNegative( + overloadedAnswers, record.correctAnswersCount, - record.correctCount, scoreInfo.correctAnswers, scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct, - rawScoreInfo.correctAnswers, - rawScoreInfo.correct + legacyScoreInfo.correctAnswers, + legacyScoreInfo.correct ); - if (correctAnswers === null) { - const comparisons = asArray(record.answerComparison).length - ? asArray(record.answerComparison) - : asArray(asObject(record.realData).answerComparison); - if (comparisons.length) { - correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; - } - } - if (correctAnswers !== null) record.correctAnswers = correctAnswers; - else if (isAnswerMap) record.correctAnswers = 0; - - const totalQuestions = firstNonNegativeScalar( + if (correct !== null) record.correctAnswers = correct; + else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0; + const total = firstNonNegative( record.totalQuestions, record.questionCount, scoreInfo.totalQuestions, scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total, - rawScoreInfo.totalQuestions, - rawScoreInfo.total + legacyScoreInfo.totalQuestions, + legacyScoreInfo.total ); - if (totalQuestions !== null) record.totalQuestions = totalQuestions; + if (total !== null) record.totalQuestions = total; + } + + function mergeAnswers(target, source) { + if (Array.isArray(source)) { + source.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1); + const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer; + if (answer !== undefined) target[questionId] = clone(answer); + }); + return; + } + for (const [questionId, answer] of Object.entries(asObject(source))) { + target[String(questionId)] = clone(answer); + } + } + + function normalizePracticeAnswers(record) { + const answers = {}; + const raw = asObject(record.rawData); + const rawReal = asObject(raw.realData); + const real = asObject(record.realData); + for (const source of [ + rawReal.answerMap, rawReal.answerList, rawReal.answers, + raw.answerMap, raw.answerList, raw.answers, + real.answerMap, real.answerList, real.answers, + record.answerMap, record.answerList, record.answers + ]) mergeAnswers(answers, source); + if (Object.keys(answers).length) record.answers = answers; + } + + function questionTypeErrorCounts(source) { + const counts = {}; + const add = (type, count = 1) => { + const key = String(type || '').trim(); + if (key && count > 0) counts[key] = (counts[key] || 0) + count; + }; + for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) { + const metrics = asObject(value); + const total = firstNonNegative(metrics.totalQuestions, metrics.total); + const correct = firstNonNegative(metrics.correctAnswers, metrics.correct); + if (total !== null && correct !== null) add(type, Math.max(0, total - correct)); + } + for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) { + if (detail && detail.isCorrect === false) add(detail.questionType || detail.type); + } + return counts; } function canonicalizeRecord(input) { @@ -1585,8 +1535,8 @@ record.metadata = asObject(record.metadata); if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; - canonicalizeAnswerSource(record); - normalizePracticeScoreFields(record); + normalizePracticeAnswers(record); + normalizePracticeScore(record); for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { if (record[field] === undefined || record[field] === null || record[field] === '') continue; const numeric = Number(record[field]); @@ -1597,97 +1547,13 @@ return jsonValue(record, 'canonical practice record'); } - function deriveQuestionTypeErrorCounts(source) { - const record = asObject(source); - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const counts = {}; - const addCount = (type, value) => { - const key = String(type || 'other').trim() || 'other'; - const amount = Math.max(0, Number(value) || 0); - if (amount > 0) counts[key] = (counts[key] || 0) + amount; - }; - const performanceSources = [ - record.questionTypePerformance, - realData.questionTypePerformance, - rawData.questionTypePerformance - ]; - let hasPerformanceData = false; - for (const performanceMap of performanceSources) { - if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; - let sourceHasPerformanceData = false; - for (const [type, value] of Object.entries(performanceMap)) { - const performance = asObject(value); - const total = Number(performance.total ?? performance.totalQuestions); - const correct = Number(performance.correct ?? performance.correctAnswers); - if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; - hasPerformanceData = true; - sourceHasPerformanceData = true; - addCount(type, total - correct); - } - if (sourceHasPerformanceData) break; - } - if (hasPerformanceData) return counts; - - const questionTypeMap = Object.assign( - {}, - asObject(rawData.questionTypeMap), - asObject(realData.questionTypeMap), - asObject(record.questionTypeMap) - ); - const normalizedTypeMap = {}; - for (const [questionId, type] of Object.entries(questionTypeMap)) { - normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; - } - const detailSources = [ - record.answerDetails, - asObject(record.scoreInfo).details, - realData.answerDetails, - asObject(realData.scoreInfo).details, - rawData.answerDetails, - asObject(rawData.scoreInfo).details - ]; - const seenQuestions = new Set(); - for (const details of detailSources) { - if (!details || typeof details !== 'object' || Array.isArray(details)) continue; - for (const [questionId, value] of Object.entries(details)) { - const detail = asObject(value); - const normalizedId = String(questionId).trim().toLowerCase(); - if (!normalizedId || seenQuestions.has(normalizedId)) continue; - let isWrong = detail.isCorrect === false || detail.correct === false; - if (detail.isCorrect === true || detail.correct === true) isWrong = false; - else if (!isWrong) { - const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); - const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); - isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); - } - if (!isWrong) continue; - seenQuestions.add(normalizedId); - addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); - } - } - return counts; - } - function lightSuiteEntry(source, fallbackType = null) { const entry = asObject(source); const scoreInfo = asObject(entry.scoreInfo); const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); const metadata = asObject(entry.metadata); - const totalQuestions = firstNonNegativeScalar( - entry.totalQuestions, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total - ) ?? 0; - const correctAnswers = firstNonNegativeScalar( - entry.correctAnswers, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct - ) ?? 0; + const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0; + const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0; const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; const accuracy = normalizeAccuracyRatio( explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), @@ -1699,14 +1565,14 @@ sessionId: entry.sessionId || null, examId: entry.examId || metadata.examId || null, title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', - type: entry.type || metadata.type || metadata.examType || fallbackType || null, + type: entry.type || metadata.type || fallbackType, date: entry.date || entry.completedAt || entry.timestamp || null, duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, totalQuestions, correctAnswers, accuracy, percentage, - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + questionTypeErrorCounts: questionTypeErrorCounts(entry) }, 'suite entry light projection'); } @@ -1743,6 +1609,7 @@ accuracy, percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + questionTypeErrorCounts: questionTypeErrorCounts(source), // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 @@ -1756,8 +1623,10 @@ 'dataSource', 'source', 'libraryConfigurationId' ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), suite: source.suite == null ? null : clone(asObject(source.suite)), - suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry( + entry, + String(source.type || '').replace(/-suite$/, '') || null + )) }, 'practice light projection'); } @@ -1778,7 +1647,7 @@ return first === undefined ? {} : clone(first); } - const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']); const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); function withoutRawData(value) { @@ -1797,11 +1666,10 @@ const detail = { recordId: source.id }; const annotations = { recordId: source.id }; for (const [key, value] of Object.entries(source)) { - if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue; if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { const next = Object.assign({}, asObject(entry)); - canonicalizeAnswerSource(next); const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); @@ -1823,7 +1691,7 @@ } // Accept the old mirror only as an input normalization boundary; it is never persisted. const realData = asObject(source.realData); const rawData = asObject(source.rawData); - for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); } for (const key of ANNOTATION_FIELDS) { @@ -2036,8 +1904,6 @@ // Entity records are authoritative. Projections are assembled on reads, never cached or // scheduled as follow-up work; this keeps a successful write immediately observable. - async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } - async function retryMergeConflict(options, task, maxAttempts = 3) { const explicitRevision = hasOwn(options, 'expectedRevision'); let lastError; @@ -2137,51 +2003,10 @@ function practiceLayerId(row) { return String(row && (row.recordId || row.id || row.sessionId) || ''); } - async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { - // The real kernel reads all three entity stores in one readonly - // IndexedDB transaction. Keep the fallback for deliberately minimal - // embedders and unit-test kernels that only expose the original methods. - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); - } - const ids = recordIds === null || recordIds === undefined - ? null - : (Array.isArray(recordIds) ? recordIds : [recordIds]) - .map((value) => String(value || '')) - .filter(Boolean); - const summaries = ids === null - ? await kernel.listEntities('practiceSummaries', { withMeta }) - : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); - const targetIds = summaries.map(practiceLayerId).filter(Boolean); - const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; - const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) - .then((rows) => rows.filter(Boolean)); - return { - practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], - practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], - practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] - }; - } async function practiceLayers(recordId, withMeta = false) { - const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta }); const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; - return { - summary: find('practiceSummaries'), - detail: find('practiceDetails'), - annotations: find('practiceAnnotations') - }; - } - async function suiteChildRecordIds(command, aggregateRecordId) { - const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); - const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); - if (sessionIds.size) { - const summaries = await kernel.listEntities('practiceSummaries'); - for (const summary of summaries) { - if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); - } - } - ids.delete(String(aggregateRecordId)); - return ids; + return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') }; } function entityRevision(row) { return row ? Number(row.revision) : 0; } function practiceUpserts(recordId, layers, existing = {}) { @@ -2193,76 +2018,30 @@ } async function joinedPractice(recordId, projection, snapshot = null) { const mode = String(projection || 'full').toLowerCase(); - const layers = snapshot || await practiceProjectionSnapshot([recordId], false, - mode === 'light' || mode === 'summary' - ? ['practiceSummaries'] - : (mode === 'detail' || mode === 'medium' - ? ['practiceSummaries', 'practiceDetails'] - : null)); - const summary = asArray(layers.practiceSummaries) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const stores = mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined); + const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores }); + const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + const summary = find('practiceSummaries'); if (!summary) return null; if (mode === 'light' || mode === 'summary') return clone(summary); - const detail = asArray(layers.practiceDetails) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const detail = find('practiceDetails'); if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); - const annotations = asArray(layers.practiceAnnotations) - .find((row) => practiceLayerId(row) === String(recordId)) || null; - return joinPracticeRecord(summary, detail, annotations, mode); - } - function practiceSummaryTime(summary) { - const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); - return Number.isFinite(time) ? time : 0; - } - function isReadingInsightSummary(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => practiceType(entry) === 'reading'); - } - return practiceType(asObject(summary)) === 'reading'; - } - function needsQuestionTypeInsightBackfill(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => - practiceType(entry) === 'reading' - && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); - } - return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode); } const practice = Object.freeze({ async list(options = {}) { await ready; const projection = String(options.projection || 'full').toLowerCase(); - const snapshot = await practiceProjectionSnapshot(null, false, - projection === 'light' || projection === 'summary' - ? ['practiceSummaries'] - : null); - const summaries = asArray(snapshot.practiceSummaries); + const summaries = await kernel.listEntities('practiceSummaries'); if (projection === 'light' || projection === 'summary') return summaries; - return (await Promise.all(summaries - .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) - .filter(Boolean); - }, - async listInsights(options = {}) { - await ready; - const requestedLimit = Number(options.limit); - const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 - ? Math.min(requestedLimit, 50) - : 10; - const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); - const summaries = asArray(snapshot.practiceSummaries) - .filter(isReadingInsightSummary) - .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) - .slice(0, limit); - return summaries.map((summary) => { - if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); - const detail = asArray(snapshot.practiceDetails) - .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; - return detail - ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) - : clone(summary); - }); + const stores = projection === 'detail' || projection === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : undefined; + const snapshot = await kernel.readPracticeSnapshot(null, { stores }); + return (await Promise.all(asArray(snapshot.practiceSummaries) + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean); }, async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, async completeAttempt(command) { @@ -2282,9 +2061,13 @@ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String); + const children = new Set((await kernel.listEntities('practiceSummaries')) + .filter((summary) => practiceRecordMatches(summary, childIdentities)) + .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId']))); + children.delete(recordId); const receipt = await retryMergeConflict(command, async () => { const existing = await practiceLayers(recordId, true); - const children = await suiteChildRecordIds(command, recordId); const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); }); @@ -2327,6 +2110,22 @@ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); }, async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async listInsights(options = {}) { + await ready; + const limit = Math.max(1, Math.min(50, Number(options.limit) || 10)); + const summaries = (await kernel.listEntities('practiceSummaries')) + .slice() + .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '') + .localeCompare(String(left.date || left.completedAt || left.timestamp || ''))) + .slice(0, limit); + return Promise.all(summaries.map(async (summary) => { + if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary); + const detail = await kernel.readEntity('practiceDetails', summary.id); + return jsonValue(Object.assign({}, clone(summary), { + questionTypeErrorCounts: questionTypeErrorCounts(detail) + }), 'practice insight'); + })); + }, async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, projectLight, projectDetail @@ -2574,88 +2373,6 @@ 'library.activeConfigurationId' ]); - function parseLegacyImportValue(value) { - if (typeof internals.parseLegacyValue !== 'function') { - throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); - } - return internals.parseLegacyValue(value); - } - - function decodePoisonedDocument(logicalKey, wrapped) { - const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; - if (!aliases || !isPlainImportObject(wrapped) - || !Object.prototype.hasOwnProperty.call(wrapped, 'key') - || !Object.prototype.hasOwnProperty.call(wrapped, 'value') - || !aliases.includes(String(wrapped.key))) { - return { matched: false, value: null }; - } - const decoded = parseLegacyImportValue(wrapped.value); - if (!isPlainImportObject(decoded)) return { matched: true, value: null }; - const overlay = {}; - for (const [key, value] of Object.entries(wrapped)) { - if (key === 'key' || key === 'value' || key === 'timestamp') continue; - overlay[key] = clone(value); - } - return { - matched: true, - value: Object.assign({}, decoded, overlay) - }; - } - - function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { - if (!envelope || envelope.state !== 'present') return envelope; - const wrapped = envelope.data; - const decoded = decodePoisonedDocument(logicalKey, wrapped); - if (!decoded.matched || !decoded.value) return envelope; - const entry = catalog.get(logicalKey); - const next = internals.makeEnvelope(entry, decoded.value, { - revision: Number(envelope.revision) || 1, - operationId: String(envelope.operationId || randomId('import-repair')), - updatedAt: envelope.updatedAt - }); - repairedKeys.push(logicalKey); - warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); - return next; - } - - function validateLibraryImportBundle(envelopes) { - const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); - if (!presentKeys.length) return { valid: true, presentKeys }; - if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { - return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; - } - const values = {}; - for (const key of LIBRARY_IMPORT_KEYS) { - const envelope = envelopes[key]; - values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; - } - const configurations = asArray(values['library.configurations']); - const indexes = asObject(values['library.importedIndexes']); - const configurationIds = new Set(); - for (const configuration of configurations) { - const source = asObject(configuration); - const id = idOf(source, ['id', 'key', 'configId']); - if (!id || !acceptedLibraryId(id) || source.builtIn === true - || !Array.isArray(indexes[id]) || !indexes[id].length) { - return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; - } - configurationIds.add(id); - } - for (const [id, index] of Object.entries(indexes)) { - if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { - return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; - } - } - const activeId = values['library.activeConfigurationId']; - if (activeId !== null && (!acceptedLibraryId(activeId) - || !configurationIds.has(String(activeId)) - || !Array.isArray(indexes[String(activeId)]) - || !indexes[String(activeId)].length)) { - return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; - } - return { valid: true, presentKeys }; - } - function canonicalizeV2Import(parsed) { const warnings = []; const repairedKeys = []; @@ -2663,41 +2380,43 @@ const envelopes = {}; for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); - if (logicalKey === 'library.activeConfigurationId' - && rawEnvelope && rawEnvelope.state === 'present' - && String(rawEnvelope.data) === '[object Object]') { + const envelope = clone(rawEnvelope); + const data = envelope && envelope.state === 'present' ? envelope.data : null; + if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') { ignoredKeys.push(logicalKey); warnings.push('Skipped poisoned active library id'); continue; } - const repairCount = repairedKeys.length; - const repaired = repairPoisonedImportEnvelope( - logicalKey, - clone(rawEnvelope), - repairedKeys, - warnings - ); - const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; - const isLegacyRowWrapper = isPlainImportObject(rawData) - && Object.prototype.hasOwnProperty.call(rawData, 'key') - && Object.prototype.hasOwnProperty.call(rawData, 'value') - && String(rawData.key || '').startsWith('exam_system_'); - if (isLegacyRowWrapper && repairedKeys.length === repairCount) { - ignoredKeys.push(logicalKey); - warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); - continue; + if (isPlainImportObject(data) + && Object.prototype.hasOwnProperty.call(data, 'key') + && Object.prototype.hasOwnProperty.call(data, 'value') + && String(data.key || '').startsWith('exam_system_')) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || []; + const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null; + if (!isPlainImportObject(decoded)) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + const overlay = Object.fromEntries(Object.entries(data) + .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp')); + envelope.data = Object.assign({}, decoded, overlay); + envelope.checksum = checksum(envelope.data); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); } - envelopes[logicalKey] = repaired; - } - const library = parsed.scope === 'full' - ? validateLibraryImportBundle(envelopes) - : { valid: true, presentKeys: [] }; - if (!library.valid) { - for (const logicalKey of library.presentKeys) { - delete envelopes[logicalKey]; - ignoredKeys.push(logicalKey); + envelopes[logicalKey] = envelope; + } + + if (parsed.scope === 'full') { + const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) { + for (const key of presentLibraryKeys) { + delete envelopes[key]; + ignoredKeys.push(key); + } + warnings.push('Skipped incomplete library data'); } - warnings.push(`Skipped unsafe library data: ${library.reason}`); } const exportableKeys = catalog.list() .filter((entry) => entry.export === true && isImportableEntry(entry)) @@ -2705,9 +2424,7 @@ const missingKeys = parsed.scope === 'full' ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) : []; - if (parsed.scope === 'full' && missingKeys.length) { - warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); - } + const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length); return { envelopes, warnings, @@ -2715,10 +2432,8 @@ ignoredKeys, missingKeys, declaredScope: parsed.scope, - effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, - trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length - ? 'trusted-full' - : 'degraded-partial' + effectiveScope: degraded ? 'partial' : parsed.scope, + trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial') }; } @@ -2979,9 +2694,6 @@ } async function currentEntitySnapshot() { - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(null, { withMeta: true }); - } const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); const result = {}; for (const store of PRACTICE_ENTITY_STORES) { @@ -3018,19 +2730,31 @@ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); continue; } - const currentRead = await kernel.read(logicalKey, { withMeta: true }); - const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; - const currentEnvelope = currentRead && currentRead.envelope; - revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + const current = await kernel.read(logicalKey, { withMeta: true }); + revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0; let next = envelope; if (!replaceDocuments && envelope.state === 'present') { - next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') }); } snapshot.envelopes[logicalKey] = next; keys.push(logicalKey); if (next.state === 'cleared') clearedKeys.push(logicalKey); } + // A full replace mirrors all exportable user data. Missing physical + // envelopes mean catalog defaults, represented here as explicit clears. + if (replaceDocuments && parsed.scope === 'full') { + for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) { + if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue; + snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, { + state: 'cleared', + operationId: randomId('import-clear') + }); + keys.push(entry.logicalKey); + clearedKeys.push(entry.logicalKey); + } + } + // Any successful practice import installs all three stores together. Merge // may update a subset only when the final recordId sets remain identical. const sourceStores = Object.keys(asObject(parsed.entities)); @@ -3243,7 +2967,7 @@ const mutation = optionsMutationOptions(options, 'vocab-words', words); return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.words', { withMeta: true }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.words', data: words, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3285,7 +3009,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3300,7 +3024,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), upserts); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3327,7 +3051,7 @@ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); list.updatedAt = nowIso(); collections[id] = list; - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3345,7 +3069,7 @@ const current = await kernel.read('vocab.lists', { withMeta: true }); const collections = Object.assign({}, asObject(current.data)); collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3411,7 +3135,7 @@ { id: listId, words: merged, updatedAt: nowIso() } ) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3444,7 +3168,7 @@ : Object.assign({}, collections, { [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) @@ -3471,7 +3195,7 @@ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - return mutateAndProject(changes, mutation); + return kernel.mutate(changes, mutation); }); } }); @@ -3606,253 +3330,95 @@ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], 'achievements.manual': ['achievement_manual_state', 'user_achievements'] }); - const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ - 'recovery.activeSessions', - 'recovery.drafts', - 'recovery.interrupted', - 'recovery.rejectedCompletions' - ]); const LEGACY_PREFERENCE_ALIASES = Object.freeze({ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' }); - function legacyRecordArray(value) { - return Array.isArray(value) ? value : asArray(asObject(value).data); - } - function mergeLegacyExternalBackup(legacyValue, externalValue) { - const legacy = Object.assign({}, asObject(legacyValue)); + function mergeLegacySources(indexedDbValue, externalValue) { + const indexedDb = asObject(indexedDbValue); const external = asObject(externalValue); - const externalRecordKey = ['practice_records', 'practiceRecords'] - .find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (externalRecordKey) { - const records = new Map(); - for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { - const recordId = idOf(record, ['id', 'recordId', 'sessionId']); - records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); - } - legacy.practice_records = Array.from(records.values()); - } - for (const [target, aliases] of Object.entries({ - user_stats: ['user_stats', 'userStats'], - exam_index: ['exam_index', 'examIndex'], - storage_version: ['storage_version', 'storageVersion'] - })) { - if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; - const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (alias) legacy[target] = clone(external[alias]); - } - return legacy; - } - - function acceptedLibraryId(value) { - const id = value === null || value === undefined ? '' : String(value).trim(); - if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; - try { return importedLibraryId(id); } catch (_) { return null; } - } - - function remapLegacyLibraryId(value) { - return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + const merged = Object.assign({}, external, indexedDb); + const records = new Map(); + const addRecords = (value) => { + const list = Array.isArray(value) ? value : asArray(asObject(value).data); + list.forEach((record) => { + const id = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record)); + }); + }; + addRecords(external.practice_records || external.practiceRecords); + addRecords(indexedDb.practice_records); + if (records.size) merged.practice_records = Array.from(records.values()); + return merged; } - async function migrateLegacyLibraryData(legacy) { - const [configMeta, indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.configurations', { withMeta: true }), - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const legacyIdMap = new Map(); + function legacyLibraryBundle(legacy) { + const idMap = new Map(); const indexes = {}; - const addLegacyIndex = (oldId, value) => { - if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; - const index = asArray(value); - if (!index.length) return; - const mappedId = remapLegacyLibraryId(oldId); - legacyIdMap.set(oldId, mappedId); - indexes[mappedId] = clone(index); - }; - - for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); - // Reconciliation is a union. A healthy current v2 value wins for the same - // deterministic library ID, while missing legacy libraries are restored. - for (const [id, value] of Object.entries(asObject(indexMeta.data))) { - if (/^exam_index_/.test(id)) addLegacyIndex(id, value); - else { - const acceptedId = acceptedLibraryId(id); - if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); - } + for (const [oldId, value] of Object.entries(asObject(legacy))) { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue; + const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`; + idMap.set(oldId, id); + indexes[id] = clone(value); } - + if (!idMap.size) return null; const configurations = new Map(); - const addConfiguration = (configuration) => { - const source = asObject(configuration); - const oldId = idOf(source, ['id', 'key', 'configId']); - if (!oldId || oldId === 'exam_index') return; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - if (!id || !asArray(indexes[id]).length) return; - configurations.set(id, Object.assign({}, clone(source), { + asArray(legacy.exam_index_configurations).forEach((configuration) => { + const oldId = idOf(configuration, ['id', 'key', 'configId']); + const id = idMap.get(oldId); + if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length })); + }); + for (const [oldId, id] of idMap) { + if (!configurations.has(id)) configurations.set(id, { id, key: id, - examCount: indexes[id].length - })); - }; - asArray(legacy.exam_index_configurations).forEach(addConfiguration); - asArray(configMeta.data).forEach(addConfiguration); - for (const [oldId, id] of legacyIdMap) { - if (!configurations.has(id)) { - configurations.set(id, { - id, - key: id, - name: `迁移的自定义题库 (${oldId})`, - examCount: indexes[id].length, - sourceType: 'legacy-import' - }); - } - } - - const resolveActive = (value) => { - const oldId = value === null || value === undefined ? '' : String(value).trim(); - if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - return id && asArray(indexes[id]).length ? id : null; - }; - const currentRawActive = activeMeta.data; - let activeId = resolveActive(currentRawActive); - const currentIsExplicitDefault = Boolean(activeMeta.envelope) - && (currentRawActive === null || String(currentRawActive || '').trim() === ''); - if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { - activeId = resolveActive(legacy.active_exam_index_key); - } - - const nextConfigurations = Array.from(configurations.values()); - const changes = []; - if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { - changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); - } - if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { - changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); - } - if (activeId !== activeMeta.data) { - changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); - } - if (changes.length) { - await kernel.mutate(changes, { - operationId: `legacy-library-repair-v2-${checksum(changes)}` + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' }); } + return { + configurations: Array.from(configurations.values()), + indexes, + activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null + }; } async function migrateLegacyData() { // Unit embedders may provide a deliberately minimal kernel bootstrap. if (typeof internals.readLegacyValues !== 'function') return; - const legacySource = await internals.readLegacyValues(); - if (legacySource && legacySource.__legacyReadComplete === false) { - throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); - } const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); const migrationState = asObject(migrationMeta.data); + const v1Complete = asObject(migrationState.v1ToV2).status === 'complete'; + const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed'; let externalBackup = null; - if (asObject(migrationState.externalBackupV1).status !== 'consumed' - && typeof internals.readLegacyExternalBackup === 'function') { - try { - externalBackup = await internals.readLegacyExternalBackup(); - } catch (error) { + if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') { + try { externalBackup = await internals.readLegacyExternalBackup(); } + catch (error) { if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } - const legacy = externalBackup - ? mergeLegacyExternalBackup(legacySource, externalBackup) - : legacySource; - if (!legacy || !Object.keys(legacy).length) return; - - const documentMetas = {}; - for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { - documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); - } - const [indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const documentRepairs = []; - const poisonedDocumentKeys = []; - for (const [logicalKey, current] of Object.entries(documentMetas)) { - if (!current.envelope || current.envelope.state !== 'present') continue; - const decoded = decodePoisonedDocument(logicalKey, current.data); - if (!decoded.matched) continue; - poisonedDocumentKeys.push(logicalKey); - const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; - const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - const legacyValue = legacyAlias ? legacy[legacyAlias] : null; - let repairValue = decoded.value; - if (isPlainImportObject(legacyValue)) { - repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); - } - if (!repairValue) continue; - documentRepairs.push({ - logicalKey, - data: repairValue, - expectedRevision: Number(current.envelope.revision) - }); - } - const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => - isPlainImportObject(value) - && Object.prototype.hasOwnProperty.call(value, 'key') - && Object.prototype.hasOwnProperty.call(value, 'value') - && /^exam_system_exam_index_/.test(String(value.key || ''))); - const poisonedActive = String(activeMeta.data) === '[object Object]'; - const libraryPoisoned = poisonedIndex || poisonedActive; - const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; - - await migrateLegacyLibraryData(legacy); - if (documentRepairs.length) { - await kernel.mutate(documentRepairs, { - operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` - }); - } + if (v1Complete && !externalBackup) return; + const indexedDb = await internals.readLegacyValues(); + if (indexedDb && indexedDb.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const legacy = mergeLegacySources(indexedDb, externalBackup); const changes = []; for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { - const currentAudit = asObject(migrationState.v1ToV2); - if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { - continue; - } const current = await kernel.getEnvelope(logicalKey); + if (current) continue; const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - if (!alias) continue; - const legacyValue = legacy[alias]; - if (!current) { - changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); - continue; - } - const isBadMigrationWrite = current.state === 'present' - && /^legacy-documents-/.test(String(current.operationId || '')); - if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { - changes.push({ - logicalKey, - data: legacyValue, - expectedRevision: Number(current.revision) - }); - continue; - } - const entry = catalog.get(logicalKey); - if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; - let merged; - try { - merged = mergeImportValue(entry, legacyValue, current.data); - } catch (error) { - if (global.console && console.warn) { - console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); - } - continue; - } - if (checksum(merged) !== checksum(current.data)) { - changes.push({ - logicalKey, - data: merged, - expectedRevision: Number(current.revision) - }); - } + if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 }); + } + const libraryBundle = legacyLibraryBundle(legacy); + if (libraryBundle) { + if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 }); } if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { const preferences = {}; @@ -3867,84 +3433,52 @@ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); } - if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` }); const recordsValue = legacy.practice_records; const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); const operations = []; - let skippedRecords = 0; - const reconciledRecordIds = new Set(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; - let canonical; - let parts; + for (const [index, record] of records.entries()) { try { - const candidate = jsonValue(record, 'legacy practice record'); - if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { - candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const candidate = clone(record); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const canonical = canonicalizeRecord(candidate); + const parts = splitPracticeRecord(canonical); + for (const [store, data] of [ + ['practiceSummaries', parts.summary], + ['practiceDetails', parts.detail], + ['practiceAnnotations', parts.annotations] + ]) { + if (!await kernel.readEntity(store, canonical.id)) { + operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 }); + } } - canonical = canonicalizeRecord(candidate); - parts = splitPracticeRecord(canonical); } catch (error) { - skippedRecords += 1; if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); - continue; } - if (reconciledRecordIds.has(canonical.id)) continue; - reconciledRecordIds.add(canonical.id); - // Storage errors are not malformed records. Let them abort this repair so the - // completion marker is not written and the next startup can retry safely. - const existing = await practiceLayers(canonical.id, true); - if (existing.summary && existing.detail && existing.annotations) continue; - operations.push(...practiceUpserts(canonical.id, parts, existing)); } if (operations.length) { - await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` }); } - const migrationAudit = { - version: 4, + + const nextMigrationState = Object.assign({}, migrationState); + if (!v1Complete) nextMigrationState.v1ToV2 = { + version: 1, status: 'complete', - mode: 'persistent-reconcile', - sourceChecksum: checksum(legacy), - sourceRecordCount: records.length, - skippedRecordCount: skippedRecords + completedAt: nowIso(), + sourceChecksum: checksum(indexedDb), + sourceRecordCount: asArray(indexedDb.practice_records).length }; - const currentAudit = asObject(migrationState.v1ToV2); - const comparableCurrentAudit = { - version: currentAudit.version, - status: currentAudit.status, - mode: currentAudit.mode, - sourceChecksum: currentAudit.sourceChecksum, - sourceRecordCount: currentAudit.sourceRecordCount, - skippedRecordCount: currentAudit.skippedRecordCount - }; - const externalAudit = externalBackup ? { + if (externalBackup) nextMigrationState.externalBackupV1 = { version: 1, status: 'consumed', + completedAt: nowIso(), sourceChecksum: checksum(externalBackup) - } : null; - const currentExternalAudit = asObject(migrationState.externalBackupV1); - if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) - || (externalAudit && (currentExternalAudit.status !== externalAudit.status - || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { - const nextMigrationState = Object.assign({}, migrationState, { - v1ToV2: Object.assign({}, migrationAudit, { - completedAt: nowIso(), - poisonDetected, - poisonedDocumentKeys, - libraryPoisoned - }) - }); - if (externalAudit) { - nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); - } - await kernel.mutate([{ - logicalKey: 'system.migrations', - data: nextMigrationState, - expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 - }], { - operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` - }); - } + }; + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` }); } const ready = kernel.initialize() diff --git a/js/bundles/practice-page-enhancer.bundle.js b/js/bundles/practice-page-enhancer.bundle.js index 32184247..bb3293df 100644 --- a/js/bundles/practice-page-enhancer.bundle.js +++ b/js/bundles/practice-page-enhancer.bundle.js @@ -1367,6 +1367,7 @@ } return ''; } + function importedLibraryId(value, options = {}) { const id = value === null || value === undefined ? '' : String(value).trim(); if (!id && options.nullable) return null; @@ -1435,143 +1436,92 @@ }; } - function nonNegativeScalar(value) { - if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; - if (typeof value !== 'number' && typeof value !== 'string') return null; - const numeric = Number(value); - return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; - } - - function firstNonNegativeScalar(...values) { + function firstNonNegative(...values) { for (const value of values) { - const numeric = nonNegativeScalar(value); - if (numeric !== null) return numeric; + if (value === null || value === undefined || value === '' || typeof value === 'object') continue; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } return null; } - function normalizeAnswerQuestionId(value, index = 0) { - const text = value === undefined || value === null ? '' : String(value).trim(); - return text || `q${index + 1}`; - } - - function normalizeAnswerValue(value) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); - if (value && typeof value === 'object') { - if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); - if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); - if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); - return jsonValue(value, 'practice answer'); + function normalizePracticeScore(record) { + const scoreInfo = asObject(record.scoreInfo); + const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo); + const overloadedAnswers = record.correctAnswers; + if (overloadedAnswers && typeof overloadedAnswers === 'object') { + record.correctAnswerMap = Object.assign( + {}, + clone(asObject(overloadedAnswers)), + clone(asObject(record.correctAnswerMap)) + ); } - return typeof value === 'string' ? value : String(value); + const correct = firstNonNegative( + overloadedAnswers, + record.correctAnswersCount, + scoreInfo.correctAnswers, + scoreInfo.correct, + legacyScoreInfo.correctAnswers, + legacyScoreInfo.correct + ); + if (correct !== null) record.correctAnswers = correct; + else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0; + const total = firstNonNegative( + record.totalQuestions, + record.questionCount, + scoreInfo.totalQuestions, + scoreInfo.total, + legacyScoreInfo.totalQuestions, + legacyScoreInfo.total + ); + if (total !== null) record.totalQuestions = total; } - function normalizeAnswerMap(value) { - const normalized = {}; - if (Array.isArray(value)) { - value.forEach((entry, index) => { - if (entry === undefined || entry === null) return; - const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; - const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); - normalized[questionId] = normalizeAnswerValue( - hasOwn(item, 'answer') ? item.answer - : hasOwn(item, 'userAnswer') ? item.userAnswer - : hasOwn(item, 'value') ? item.value - : entry - ); + function mergeAnswers(target, source) { + if (Array.isArray(source)) { + source.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1); + const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer; + if (answer !== undefined) target[questionId] = clone(answer); }); - return normalized; + return; } - if (!value || typeof value !== 'object') return normalized; - Object.entries(value).forEach(([questionId, answer], index) => { - if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; - normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); - }); - return normalized; - } - - function mergeAnswerMaps(...sources) { - const merged = {}; - for (const source of sources) { - const normalized = normalizeAnswerMap(source); - for (const [questionId, answer] of Object.entries(normalized)) { - if (!hasOwn(merged, questionId)) merged[questionId] = answer; - } + for (const [questionId, answer] of Object.entries(asObject(source))) { + target[String(questionId)] = clone(answer); } - return merged; } - function canonicalizeAnswerSource(record) { - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const rawRealData = asObject(rawData.realData); - record.answers = mergeAnswerMaps( - record.answers, - record.answerMap, - record.answerList, - realData.answers, - realData.answerMap, - rawData.answers, - rawData.answerMap, - rawRealData.answers - ); - // These names remain accepted only at the compatibility boundary. The - // canonical detail layer owns one user-answer source: `answers`. - delete record.answerMap; - delete record.answerList; + function normalizePracticeAnswers(record) { + const answers = {}; + const raw = asObject(record.rawData); + const rawReal = asObject(raw.realData); + const real = asObject(record.realData); + for (const source of [ + rawReal.answerMap, rawReal.answerList, rawReal.answers, + raw.answerMap, raw.answerList, raw.answers, + real.answerMap, real.answerList, real.answers, + record.answerMap, record.answerList, record.answers + ]) mergeAnswers(answers, source); + if (Object.keys(answers).length) record.answers = answers; } - function normalizePracticeScoreFields(record) { - const scoreInfo = asObject(record.scoreInfo); - const realScoreInfo = asObject(asObject(record.realData).scoreInfo); - const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); - const overloadedCorrectAnswers = record.correctAnswers; - const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; - if (isAnswerMap) { - const existingMap = record.correctAnswerMap; - const overloadedObject = asObject(overloadedCorrectAnswers); - const existingObject = asObject(existingMap); - if (Object.keys(overloadedObject).length) { - record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); - } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { - record.correctAnswerMap = clone(overloadedCorrectAnswers); - } - } - - let correctAnswers = firstNonNegativeScalar( - overloadedCorrectAnswers, - record.correctAnswersCount, - record.correctCount, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct, - rawScoreInfo.correctAnswers, - rawScoreInfo.correct - ); - if (correctAnswers === null) { - const comparisons = asArray(record.answerComparison).length - ? asArray(record.answerComparison) - : asArray(asObject(record.realData).answerComparison); - if (comparisons.length) { - correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; - } + function questionTypeErrorCounts(source) { + const counts = {}; + const add = (type, count = 1) => { + const key = String(type || '').trim(); + if (key && count > 0) counts[key] = (counts[key] || 0) + count; + }; + for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) { + const metrics = asObject(value); + const total = firstNonNegative(metrics.totalQuestions, metrics.total); + const correct = firstNonNegative(metrics.correctAnswers, metrics.correct); + if (total !== null && correct !== null) add(type, Math.max(0, total - correct)); } - if (correctAnswers !== null) record.correctAnswers = correctAnswers; - else if (isAnswerMap) record.correctAnswers = 0; - - const totalQuestions = firstNonNegativeScalar( - record.totalQuestions, - record.questionCount, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total, - rawScoreInfo.totalQuestions, - rawScoreInfo.total - ); - if (totalQuestions !== null) record.totalQuestions = totalQuestions; + for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) { + if (detail && detail.isCorrect === false) add(detail.questionType || detail.type); + } + return counts; } function canonicalizeRecord(input) { @@ -1585,8 +1535,8 @@ record.metadata = asObject(record.metadata); if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; - canonicalizeAnswerSource(record); - normalizePracticeScoreFields(record); + normalizePracticeAnswers(record); + normalizePracticeScore(record); for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { if (record[field] === undefined || record[field] === null || record[field] === '') continue; const numeric = Number(record[field]); @@ -1597,97 +1547,13 @@ return jsonValue(record, 'canonical practice record'); } - function deriveQuestionTypeErrorCounts(source) { - const record = asObject(source); - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const counts = {}; - const addCount = (type, value) => { - const key = String(type || 'other').trim() || 'other'; - const amount = Math.max(0, Number(value) || 0); - if (amount > 0) counts[key] = (counts[key] || 0) + amount; - }; - const performanceSources = [ - record.questionTypePerformance, - realData.questionTypePerformance, - rawData.questionTypePerformance - ]; - let hasPerformanceData = false; - for (const performanceMap of performanceSources) { - if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; - let sourceHasPerformanceData = false; - for (const [type, value] of Object.entries(performanceMap)) { - const performance = asObject(value); - const total = Number(performance.total ?? performance.totalQuestions); - const correct = Number(performance.correct ?? performance.correctAnswers); - if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; - hasPerformanceData = true; - sourceHasPerformanceData = true; - addCount(type, total - correct); - } - if (sourceHasPerformanceData) break; - } - if (hasPerformanceData) return counts; - - const questionTypeMap = Object.assign( - {}, - asObject(rawData.questionTypeMap), - asObject(realData.questionTypeMap), - asObject(record.questionTypeMap) - ); - const normalizedTypeMap = {}; - for (const [questionId, type] of Object.entries(questionTypeMap)) { - normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; - } - const detailSources = [ - record.answerDetails, - asObject(record.scoreInfo).details, - realData.answerDetails, - asObject(realData.scoreInfo).details, - rawData.answerDetails, - asObject(rawData.scoreInfo).details - ]; - const seenQuestions = new Set(); - for (const details of detailSources) { - if (!details || typeof details !== 'object' || Array.isArray(details)) continue; - for (const [questionId, value] of Object.entries(details)) { - const detail = asObject(value); - const normalizedId = String(questionId).trim().toLowerCase(); - if (!normalizedId || seenQuestions.has(normalizedId)) continue; - let isWrong = detail.isCorrect === false || detail.correct === false; - if (detail.isCorrect === true || detail.correct === true) isWrong = false; - else if (!isWrong) { - const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); - const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); - isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); - } - if (!isWrong) continue; - seenQuestions.add(normalizedId); - addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); - } - } - return counts; - } - function lightSuiteEntry(source, fallbackType = null) { const entry = asObject(source); const scoreInfo = asObject(entry.scoreInfo); const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); const metadata = asObject(entry.metadata); - const totalQuestions = firstNonNegativeScalar( - entry.totalQuestions, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total - ) ?? 0; - const correctAnswers = firstNonNegativeScalar( - entry.correctAnswers, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct - ) ?? 0; + const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0; + const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0; const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; const accuracy = normalizeAccuracyRatio( explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), @@ -1699,14 +1565,14 @@ sessionId: entry.sessionId || null, examId: entry.examId || metadata.examId || null, title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', - type: entry.type || metadata.type || metadata.examType || fallbackType || null, + type: entry.type || metadata.type || fallbackType, date: entry.date || entry.completedAt || entry.timestamp || null, duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, totalQuestions, correctAnswers, accuracy, percentage, - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + questionTypeErrorCounts: questionTypeErrorCounts(entry) }, 'suite entry light projection'); } @@ -1743,6 +1609,7 @@ accuracy, percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + questionTypeErrorCounts: questionTypeErrorCounts(source), // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 @@ -1756,8 +1623,10 @@ 'dataSource', 'source', 'libraryConfigurationId' ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), suite: source.suite == null ? null : clone(asObject(source.suite)), - suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry( + entry, + String(source.type || '').replace(/-suite$/, '') || null + )) }, 'practice light projection'); } @@ -1778,7 +1647,7 @@ return first === undefined ? {} : clone(first); } - const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']); const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); function withoutRawData(value) { @@ -1797,11 +1666,10 @@ const detail = { recordId: source.id }; const annotations = { recordId: source.id }; for (const [key, value] of Object.entries(source)) { - if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue; if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { const next = Object.assign({}, asObject(entry)); - canonicalizeAnswerSource(next); const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); @@ -1823,7 +1691,7 @@ } // Accept the old mirror only as an input normalization boundary; it is never persisted. const realData = asObject(source.realData); const rawData = asObject(source.rawData); - for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); } for (const key of ANNOTATION_FIELDS) { @@ -2036,8 +1904,6 @@ // Entity records are authoritative. Projections are assembled on reads, never cached or // scheduled as follow-up work; this keeps a successful write immediately observable. - async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } - async function retryMergeConflict(options, task, maxAttempts = 3) { const explicitRevision = hasOwn(options, 'expectedRevision'); let lastError; @@ -2137,51 +2003,10 @@ function practiceLayerId(row) { return String(row && (row.recordId || row.id || row.sessionId) || ''); } - async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { - // The real kernel reads all three entity stores in one readonly - // IndexedDB transaction. Keep the fallback for deliberately minimal - // embedders and unit-test kernels that only expose the original methods. - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); - } - const ids = recordIds === null || recordIds === undefined - ? null - : (Array.isArray(recordIds) ? recordIds : [recordIds]) - .map((value) => String(value || '')) - .filter(Boolean); - const summaries = ids === null - ? await kernel.listEntities('practiceSummaries', { withMeta }) - : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); - const targetIds = summaries.map(practiceLayerId).filter(Boolean); - const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; - const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) - .then((rows) => rows.filter(Boolean)); - return { - practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], - practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], - practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] - }; - } async function practiceLayers(recordId, withMeta = false) { - const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta }); const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; - return { - summary: find('practiceSummaries'), - detail: find('practiceDetails'), - annotations: find('practiceAnnotations') - }; - } - async function suiteChildRecordIds(command, aggregateRecordId) { - const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); - const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); - if (sessionIds.size) { - const summaries = await kernel.listEntities('practiceSummaries'); - for (const summary of summaries) { - if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); - } - } - ids.delete(String(aggregateRecordId)); - return ids; + return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') }; } function entityRevision(row) { return row ? Number(row.revision) : 0; } function practiceUpserts(recordId, layers, existing = {}) { @@ -2193,76 +2018,30 @@ } async function joinedPractice(recordId, projection, snapshot = null) { const mode = String(projection || 'full').toLowerCase(); - const layers = snapshot || await practiceProjectionSnapshot([recordId], false, - mode === 'light' || mode === 'summary' - ? ['practiceSummaries'] - : (mode === 'detail' || mode === 'medium' - ? ['practiceSummaries', 'practiceDetails'] - : null)); - const summary = asArray(layers.practiceSummaries) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const stores = mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined); + const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores }); + const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + const summary = find('practiceSummaries'); if (!summary) return null; if (mode === 'light' || mode === 'summary') return clone(summary); - const detail = asArray(layers.practiceDetails) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const detail = find('practiceDetails'); if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); - const annotations = asArray(layers.practiceAnnotations) - .find((row) => practiceLayerId(row) === String(recordId)) || null; - return joinPracticeRecord(summary, detail, annotations, mode); - } - function practiceSummaryTime(summary) { - const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); - return Number.isFinite(time) ? time : 0; - } - function isReadingInsightSummary(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => practiceType(entry) === 'reading'); - } - return practiceType(asObject(summary)) === 'reading'; - } - function needsQuestionTypeInsightBackfill(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => - practiceType(entry) === 'reading' - && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); - } - return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode); } const practice = Object.freeze({ async list(options = {}) { await ready; const projection = String(options.projection || 'full').toLowerCase(); - const snapshot = await practiceProjectionSnapshot(null, false, - projection === 'light' || projection === 'summary' - ? ['practiceSummaries'] - : null); - const summaries = asArray(snapshot.practiceSummaries); + const summaries = await kernel.listEntities('practiceSummaries'); if (projection === 'light' || projection === 'summary') return summaries; - return (await Promise.all(summaries - .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) - .filter(Boolean); - }, - async listInsights(options = {}) { - await ready; - const requestedLimit = Number(options.limit); - const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 - ? Math.min(requestedLimit, 50) - : 10; - const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); - const summaries = asArray(snapshot.practiceSummaries) - .filter(isReadingInsightSummary) - .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) - .slice(0, limit); - return summaries.map((summary) => { - if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); - const detail = asArray(snapshot.practiceDetails) - .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; - return detail - ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) - : clone(summary); - }); + const stores = projection === 'detail' || projection === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : undefined; + const snapshot = await kernel.readPracticeSnapshot(null, { stores }); + return (await Promise.all(asArray(snapshot.practiceSummaries) + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean); }, async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, async completeAttempt(command) { @@ -2282,9 +2061,13 @@ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String); + const children = new Set((await kernel.listEntities('practiceSummaries')) + .filter((summary) => practiceRecordMatches(summary, childIdentities)) + .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId']))); + children.delete(recordId); const receipt = await retryMergeConflict(command, async () => { const existing = await practiceLayers(recordId, true); - const children = await suiteChildRecordIds(command, recordId); const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); }); @@ -2327,6 +2110,22 @@ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); }, async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async listInsights(options = {}) { + await ready; + const limit = Math.max(1, Math.min(50, Number(options.limit) || 10)); + const summaries = (await kernel.listEntities('practiceSummaries')) + .slice() + .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '') + .localeCompare(String(left.date || left.completedAt || left.timestamp || ''))) + .slice(0, limit); + return Promise.all(summaries.map(async (summary) => { + if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary); + const detail = await kernel.readEntity('practiceDetails', summary.id); + return jsonValue(Object.assign({}, clone(summary), { + questionTypeErrorCounts: questionTypeErrorCounts(detail) + }), 'practice insight'); + })); + }, async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, projectLight, projectDetail @@ -2574,88 +2373,6 @@ 'library.activeConfigurationId' ]); - function parseLegacyImportValue(value) { - if (typeof internals.parseLegacyValue !== 'function') { - throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); - } - return internals.parseLegacyValue(value); - } - - function decodePoisonedDocument(logicalKey, wrapped) { - const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; - if (!aliases || !isPlainImportObject(wrapped) - || !Object.prototype.hasOwnProperty.call(wrapped, 'key') - || !Object.prototype.hasOwnProperty.call(wrapped, 'value') - || !aliases.includes(String(wrapped.key))) { - return { matched: false, value: null }; - } - const decoded = parseLegacyImportValue(wrapped.value); - if (!isPlainImportObject(decoded)) return { matched: true, value: null }; - const overlay = {}; - for (const [key, value] of Object.entries(wrapped)) { - if (key === 'key' || key === 'value' || key === 'timestamp') continue; - overlay[key] = clone(value); - } - return { - matched: true, - value: Object.assign({}, decoded, overlay) - }; - } - - function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { - if (!envelope || envelope.state !== 'present') return envelope; - const wrapped = envelope.data; - const decoded = decodePoisonedDocument(logicalKey, wrapped); - if (!decoded.matched || !decoded.value) return envelope; - const entry = catalog.get(logicalKey); - const next = internals.makeEnvelope(entry, decoded.value, { - revision: Number(envelope.revision) || 1, - operationId: String(envelope.operationId || randomId('import-repair')), - updatedAt: envelope.updatedAt - }); - repairedKeys.push(logicalKey); - warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); - return next; - } - - function validateLibraryImportBundle(envelopes) { - const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); - if (!presentKeys.length) return { valid: true, presentKeys }; - if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { - return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; - } - const values = {}; - for (const key of LIBRARY_IMPORT_KEYS) { - const envelope = envelopes[key]; - values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; - } - const configurations = asArray(values['library.configurations']); - const indexes = asObject(values['library.importedIndexes']); - const configurationIds = new Set(); - for (const configuration of configurations) { - const source = asObject(configuration); - const id = idOf(source, ['id', 'key', 'configId']); - if (!id || !acceptedLibraryId(id) || source.builtIn === true - || !Array.isArray(indexes[id]) || !indexes[id].length) { - return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; - } - configurationIds.add(id); - } - for (const [id, index] of Object.entries(indexes)) { - if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { - return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; - } - } - const activeId = values['library.activeConfigurationId']; - if (activeId !== null && (!acceptedLibraryId(activeId) - || !configurationIds.has(String(activeId)) - || !Array.isArray(indexes[String(activeId)]) - || !indexes[String(activeId)].length)) { - return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; - } - return { valid: true, presentKeys }; - } - function canonicalizeV2Import(parsed) { const warnings = []; const repairedKeys = []; @@ -2663,41 +2380,43 @@ const envelopes = {}; for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); - if (logicalKey === 'library.activeConfigurationId' - && rawEnvelope && rawEnvelope.state === 'present' - && String(rawEnvelope.data) === '[object Object]') { + const envelope = clone(rawEnvelope); + const data = envelope && envelope.state === 'present' ? envelope.data : null; + if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') { ignoredKeys.push(logicalKey); warnings.push('Skipped poisoned active library id'); continue; } - const repairCount = repairedKeys.length; - const repaired = repairPoisonedImportEnvelope( - logicalKey, - clone(rawEnvelope), - repairedKeys, - warnings - ); - const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; - const isLegacyRowWrapper = isPlainImportObject(rawData) - && Object.prototype.hasOwnProperty.call(rawData, 'key') - && Object.prototype.hasOwnProperty.call(rawData, 'value') - && String(rawData.key || '').startsWith('exam_system_'); - if (isLegacyRowWrapper && repairedKeys.length === repairCount) { - ignoredKeys.push(logicalKey); - warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); - continue; + if (isPlainImportObject(data) + && Object.prototype.hasOwnProperty.call(data, 'key') + && Object.prototype.hasOwnProperty.call(data, 'value') + && String(data.key || '').startsWith('exam_system_')) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || []; + const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null; + if (!isPlainImportObject(decoded)) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + const overlay = Object.fromEntries(Object.entries(data) + .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp')); + envelope.data = Object.assign({}, decoded, overlay); + envelope.checksum = checksum(envelope.data); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); } - envelopes[logicalKey] = repaired; + envelopes[logicalKey] = envelope; } - const library = parsed.scope === 'full' - ? validateLibraryImportBundle(envelopes) - : { valid: true, presentKeys: [] }; - if (!library.valid) { - for (const logicalKey of library.presentKeys) { - delete envelopes[logicalKey]; - ignoredKeys.push(logicalKey); + + if (parsed.scope === 'full') { + const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) { + for (const key of presentLibraryKeys) { + delete envelopes[key]; + ignoredKeys.push(key); + } + warnings.push('Skipped incomplete library data'); } - warnings.push(`Skipped unsafe library data: ${library.reason}`); } const exportableKeys = catalog.list() .filter((entry) => entry.export === true && isImportableEntry(entry)) @@ -2705,9 +2424,7 @@ const missingKeys = parsed.scope === 'full' ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) : []; - if (parsed.scope === 'full' && missingKeys.length) { - warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); - } + const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length); return { envelopes, warnings, @@ -2715,10 +2432,8 @@ ignoredKeys, missingKeys, declaredScope: parsed.scope, - effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, - trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length - ? 'trusted-full' - : 'degraded-partial' + effectiveScope: degraded ? 'partial' : parsed.scope, + trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial') }; } @@ -2979,9 +2694,6 @@ } async function currentEntitySnapshot() { - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(null, { withMeta: true }); - } const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); const result = {}; for (const store of PRACTICE_ENTITY_STORES) { @@ -3018,19 +2730,31 @@ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); continue; } - const currentRead = await kernel.read(logicalKey, { withMeta: true }); - const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; - const currentEnvelope = currentRead && currentRead.envelope; - revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + const current = await kernel.read(logicalKey, { withMeta: true }); + revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0; let next = envelope; if (!replaceDocuments && envelope.state === 'present') { - next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') }); } snapshot.envelopes[logicalKey] = next; keys.push(logicalKey); if (next.state === 'cleared') clearedKeys.push(logicalKey); } + // A full replace mirrors all exportable user data. Missing physical + // envelopes mean catalog defaults, represented here as explicit clears. + if (replaceDocuments && parsed.scope === 'full') { + for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) { + if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue; + snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, { + state: 'cleared', + operationId: randomId('import-clear') + }); + keys.push(entry.logicalKey); + clearedKeys.push(entry.logicalKey); + } + } + // Any successful practice import installs all three stores together. Merge // may update a subset only when the final recordId sets remain identical. const sourceStores = Object.keys(asObject(parsed.entities)); @@ -3243,7 +2967,7 @@ const mutation = optionsMutationOptions(options, 'vocab-words', words); return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.words', { withMeta: true }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.words', data: words, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3285,7 +3009,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3300,7 +3024,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), upserts); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3327,7 +3051,7 @@ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); list.updatedAt = nowIso(); collections[id] = list; - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3345,7 +3069,7 @@ const current = await kernel.read('vocab.lists', { withMeta: true }); const collections = Object.assign({}, asObject(current.data)); collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3411,7 +3135,7 @@ { id: listId, words: merged, updatedAt: nowIso() } ) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3444,7 +3168,7 @@ : Object.assign({}, collections, { [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) @@ -3471,7 +3195,7 @@ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - return mutateAndProject(changes, mutation); + return kernel.mutate(changes, mutation); }); } }); @@ -3606,253 +3330,95 @@ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], 'achievements.manual': ['achievement_manual_state', 'user_achievements'] }); - const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ - 'recovery.activeSessions', - 'recovery.drafts', - 'recovery.interrupted', - 'recovery.rejectedCompletions' - ]); const LEGACY_PREFERENCE_ALIASES = Object.freeze({ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' }); - function legacyRecordArray(value) { - return Array.isArray(value) ? value : asArray(asObject(value).data); - } - function mergeLegacyExternalBackup(legacyValue, externalValue) { - const legacy = Object.assign({}, asObject(legacyValue)); + function mergeLegacySources(indexedDbValue, externalValue) { + const indexedDb = asObject(indexedDbValue); const external = asObject(externalValue); - const externalRecordKey = ['practice_records', 'practiceRecords'] - .find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (externalRecordKey) { - const records = new Map(); - for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { - const recordId = idOf(record, ['id', 'recordId', 'sessionId']); - records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); - } - legacy.practice_records = Array.from(records.values()); - } - for (const [target, aliases] of Object.entries({ - user_stats: ['user_stats', 'userStats'], - exam_index: ['exam_index', 'examIndex'], - storage_version: ['storage_version', 'storageVersion'] - })) { - if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; - const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (alias) legacy[target] = clone(external[alias]); - } - return legacy; - } - - function acceptedLibraryId(value) { - const id = value === null || value === undefined ? '' : String(value).trim(); - if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; - try { return importedLibraryId(id); } catch (_) { return null; } - } - - function remapLegacyLibraryId(value) { - return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + const merged = Object.assign({}, external, indexedDb); + const records = new Map(); + const addRecords = (value) => { + const list = Array.isArray(value) ? value : asArray(asObject(value).data); + list.forEach((record) => { + const id = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record)); + }); + }; + addRecords(external.practice_records || external.practiceRecords); + addRecords(indexedDb.practice_records); + if (records.size) merged.practice_records = Array.from(records.values()); + return merged; } - async function migrateLegacyLibraryData(legacy) { - const [configMeta, indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.configurations', { withMeta: true }), - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const legacyIdMap = new Map(); + function legacyLibraryBundle(legacy) { + const idMap = new Map(); const indexes = {}; - const addLegacyIndex = (oldId, value) => { - if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; - const index = asArray(value); - if (!index.length) return; - const mappedId = remapLegacyLibraryId(oldId); - legacyIdMap.set(oldId, mappedId); - indexes[mappedId] = clone(index); - }; - - for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); - // Reconciliation is a union. A healthy current v2 value wins for the same - // deterministic library ID, while missing legacy libraries are restored. - for (const [id, value] of Object.entries(asObject(indexMeta.data))) { - if (/^exam_index_/.test(id)) addLegacyIndex(id, value); - else { - const acceptedId = acceptedLibraryId(id); - if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); - } + for (const [oldId, value] of Object.entries(asObject(legacy))) { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue; + const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`; + idMap.set(oldId, id); + indexes[id] = clone(value); } - + if (!idMap.size) return null; const configurations = new Map(); - const addConfiguration = (configuration) => { - const source = asObject(configuration); - const oldId = idOf(source, ['id', 'key', 'configId']); - if (!oldId || oldId === 'exam_index') return; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - if (!id || !asArray(indexes[id]).length) return; - configurations.set(id, Object.assign({}, clone(source), { + asArray(legacy.exam_index_configurations).forEach((configuration) => { + const oldId = idOf(configuration, ['id', 'key', 'configId']); + const id = idMap.get(oldId); + if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length })); + }); + for (const [oldId, id] of idMap) { + if (!configurations.has(id)) configurations.set(id, { id, key: id, - examCount: indexes[id].length - })); - }; - asArray(legacy.exam_index_configurations).forEach(addConfiguration); - asArray(configMeta.data).forEach(addConfiguration); - for (const [oldId, id] of legacyIdMap) { - if (!configurations.has(id)) { - configurations.set(id, { - id, - key: id, - name: `迁移的自定义题库 (${oldId})`, - examCount: indexes[id].length, - sourceType: 'legacy-import' - }); - } - } - - const resolveActive = (value) => { - const oldId = value === null || value === undefined ? '' : String(value).trim(); - if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - return id && asArray(indexes[id]).length ? id : null; - }; - const currentRawActive = activeMeta.data; - let activeId = resolveActive(currentRawActive); - const currentIsExplicitDefault = Boolean(activeMeta.envelope) - && (currentRawActive === null || String(currentRawActive || '').trim() === ''); - if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { - activeId = resolveActive(legacy.active_exam_index_key); - } - - const nextConfigurations = Array.from(configurations.values()); - const changes = []; - if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { - changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); - } - if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { - changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); - } - if (activeId !== activeMeta.data) { - changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); - } - if (changes.length) { - await kernel.mutate(changes, { - operationId: `legacy-library-repair-v2-${checksum(changes)}` + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' }); } + return { + configurations: Array.from(configurations.values()), + indexes, + activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null + }; } async function migrateLegacyData() { // Unit embedders may provide a deliberately minimal kernel bootstrap. if (typeof internals.readLegacyValues !== 'function') return; - const legacySource = await internals.readLegacyValues(); - if (legacySource && legacySource.__legacyReadComplete === false) { - throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); - } const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); const migrationState = asObject(migrationMeta.data); + const v1Complete = asObject(migrationState.v1ToV2).status === 'complete'; + const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed'; let externalBackup = null; - if (asObject(migrationState.externalBackupV1).status !== 'consumed' - && typeof internals.readLegacyExternalBackup === 'function') { - try { - externalBackup = await internals.readLegacyExternalBackup(); - } catch (error) { + if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') { + try { externalBackup = await internals.readLegacyExternalBackup(); } + catch (error) { if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } - const legacy = externalBackup - ? mergeLegacyExternalBackup(legacySource, externalBackup) - : legacySource; - if (!legacy || !Object.keys(legacy).length) return; - - const documentMetas = {}; - for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { - documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); - } - const [indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const documentRepairs = []; - const poisonedDocumentKeys = []; - for (const [logicalKey, current] of Object.entries(documentMetas)) { - if (!current.envelope || current.envelope.state !== 'present') continue; - const decoded = decodePoisonedDocument(logicalKey, current.data); - if (!decoded.matched) continue; - poisonedDocumentKeys.push(logicalKey); - const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; - const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - const legacyValue = legacyAlias ? legacy[legacyAlias] : null; - let repairValue = decoded.value; - if (isPlainImportObject(legacyValue)) { - repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); - } - if (!repairValue) continue; - documentRepairs.push({ - logicalKey, - data: repairValue, - expectedRevision: Number(current.envelope.revision) - }); - } - const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => - isPlainImportObject(value) - && Object.prototype.hasOwnProperty.call(value, 'key') - && Object.prototype.hasOwnProperty.call(value, 'value') - && /^exam_system_exam_index_/.test(String(value.key || ''))); - const poisonedActive = String(activeMeta.data) === '[object Object]'; - const libraryPoisoned = poisonedIndex || poisonedActive; - const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; - - await migrateLegacyLibraryData(legacy); - if (documentRepairs.length) { - await kernel.mutate(documentRepairs, { - operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` - }); - } + if (v1Complete && !externalBackup) return; + const indexedDb = await internals.readLegacyValues(); + if (indexedDb && indexedDb.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const legacy = mergeLegacySources(indexedDb, externalBackup); const changes = []; for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { - const currentAudit = asObject(migrationState.v1ToV2); - if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { - continue; - } const current = await kernel.getEnvelope(logicalKey); + if (current) continue; const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - if (!alias) continue; - const legacyValue = legacy[alias]; - if (!current) { - changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); - continue; - } - const isBadMigrationWrite = current.state === 'present' - && /^legacy-documents-/.test(String(current.operationId || '')); - if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { - changes.push({ - logicalKey, - data: legacyValue, - expectedRevision: Number(current.revision) - }); - continue; - } - const entry = catalog.get(logicalKey); - if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; - let merged; - try { - merged = mergeImportValue(entry, legacyValue, current.data); - } catch (error) { - if (global.console && console.warn) { - console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); - } - continue; - } - if (checksum(merged) !== checksum(current.data)) { - changes.push({ - logicalKey, - data: merged, - expectedRevision: Number(current.revision) - }); - } + if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 }); + } + const libraryBundle = legacyLibraryBundle(legacy); + if (libraryBundle) { + if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 }); } if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { const preferences = {}; @@ -3867,84 +3433,52 @@ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); } - if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` }); const recordsValue = legacy.practice_records; const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); const operations = []; - let skippedRecords = 0; - const reconciledRecordIds = new Set(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; - let canonical; - let parts; + for (const [index, record] of records.entries()) { try { - const candidate = jsonValue(record, 'legacy practice record'); - if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { - candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const candidate = clone(record); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const canonical = canonicalizeRecord(candidate); + const parts = splitPracticeRecord(canonical); + for (const [store, data] of [ + ['practiceSummaries', parts.summary], + ['practiceDetails', parts.detail], + ['practiceAnnotations', parts.annotations] + ]) { + if (!await kernel.readEntity(store, canonical.id)) { + operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 }); + } } - canonical = canonicalizeRecord(candidate); - parts = splitPracticeRecord(canonical); } catch (error) { - skippedRecords += 1; if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); - continue; } - if (reconciledRecordIds.has(canonical.id)) continue; - reconciledRecordIds.add(canonical.id); - // Storage errors are not malformed records. Let them abort this repair so the - // completion marker is not written and the next startup can retry safely. - const existing = await practiceLayers(canonical.id, true); - if (existing.summary && existing.detail && existing.annotations) continue; - operations.push(...practiceUpserts(canonical.id, parts, existing)); } if (operations.length) { - await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` }); } - const migrationAudit = { - version: 4, + + const nextMigrationState = Object.assign({}, migrationState); + if (!v1Complete) nextMigrationState.v1ToV2 = { + version: 1, status: 'complete', - mode: 'persistent-reconcile', - sourceChecksum: checksum(legacy), - sourceRecordCount: records.length, - skippedRecordCount: skippedRecords + completedAt: nowIso(), + sourceChecksum: checksum(indexedDb), + sourceRecordCount: asArray(indexedDb.practice_records).length }; - const currentAudit = asObject(migrationState.v1ToV2); - const comparableCurrentAudit = { - version: currentAudit.version, - status: currentAudit.status, - mode: currentAudit.mode, - sourceChecksum: currentAudit.sourceChecksum, - sourceRecordCount: currentAudit.sourceRecordCount, - skippedRecordCount: currentAudit.skippedRecordCount - }; - const externalAudit = externalBackup ? { + if (externalBackup) nextMigrationState.externalBackupV1 = { version: 1, status: 'consumed', + completedAt: nowIso(), sourceChecksum: checksum(externalBackup) - } : null; - const currentExternalAudit = asObject(migrationState.externalBackupV1); - if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) - || (externalAudit && (currentExternalAudit.status !== externalAudit.status - || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { - const nextMigrationState = Object.assign({}, migrationState, { - v1ToV2: Object.assign({}, migrationAudit, { - completedAt: nowIso(), - poisonDetected, - poisonedDocumentKeys, - libraryPoisoned - }) - }); - if (externalAudit) { - nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); - } - await kernel.mutate([{ - logicalKey: 'system.migrations', - data: nextMigrationState, - expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 - }], { - operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` - }); - } + }; + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` }); } const ready = kernel.initialize() diff --git a/js/bundles/reading-page.bundle.js b/js/bundles/reading-page.bundle.js index 729ffe42..b27021f6 100644 --- a/js/bundles/reading-page.bundle.js +++ b/js/bundles/reading-page.bundle.js @@ -1367,6 +1367,7 @@ } return ''; } + function importedLibraryId(value, options = {}) { const id = value === null || value === undefined ? '' : String(value).trim(); if (!id && options.nullable) return null; @@ -1435,143 +1436,92 @@ }; } - function nonNegativeScalar(value) { - if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; - if (typeof value !== 'number' && typeof value !== 'string') return null; - const numeric = Number(value); - return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; - } - - function firstNonNegativeScalar(...values) { + function firstNonNegative(...values) { for (const value of values) { - const numeric = nonNegativeScalar(value); - if (numeric !== null) return numeric; + if (value === null || value === undefined || value === '' || typeof value === 'object') continue; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } return null; } - function normalizeAnswerQuestionId(value, index = 0) { - const text = value === undefined || value === null ? '' : String(value).trim(); - return text || `q${index + 1}`; - } - - function normalizeAnswerValue(value) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); - if (value && typeof value === 'object') { - if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); - if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); - if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); - return jsonValue(value, 'practice answer'); - } - return typeof value === 'string' ? value : String(value); - } - - function normalizeAnswerMap(value) { - const normalized = {}; - if (Array.isArray(value)) { - value.forEach((entry, index) => { - if (entry === undefined || entry === null) return; - const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; - const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); - normalized[questionId] = normalizeAnswerValue( - hasOwn(item, 'answer') ? item.answer - : hasOwn(item, 'userAnswer') ? item.userAnswer - : hasOwn(item, 'value') ? item.value - : entry - ); - }); - return normalized; - } - if (!value || typeof value !== 'object') return normalized; - Object.entries(value).forEach(([questionId, answer], index) => { - if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; - normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); - }); - return normalized; - } - - function mergeAnswerMaps(...sources) { - const merged = {}; - for (const source of sources) { - const normalized = normalizeAnswerMap(source); - for (const [questionId, answer] of Object.entries(normalized)) { - if (!hasOwn(merged, questionId)) merged[questionId] = answer; - } - } - return merged; - } - - function canonicalizeAnswerSource(record) { - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const rawRealData = asObject(rawData.realData); - record.answers = mergeAnswerMaps( - record.answers, - record.answerMap, - record.answerList, - realData.answers, - realData.answerMap, - rawData.answers, - rawData.answerMap, - rawRealData.answers - ); - // These names remain accepted only at the compatibility boundary. The - // canonical detail layer owns one user-answer source: `answers`. - delete record.answerMap; - delete record.answerList; - } - - function normalizePracticeScoreFields(record) { + function normalizePracticeScore(record) { const scoreInfo = asObject(record.scoreInfo); - const realScoreInfo = asObject(asObject(record.realData).scoreInfo); - const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); - const overloadedCorrectAnswers = record.correctAnswers; - const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; - if (isAnswerMap) { - const existingMap = record.correctAnswerMap; - const overloadedObject = asObject(overloadedCorrectAnswers); - const existingObject = asObject(existingMap); - if (Object.keys(overloadedObject).length) { - record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); - } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { - record.correctAnswerMap = clone(overloadedCorrectAnswers); - } - } - - let correctAnswers = firstNonNegativeScalar( - overloadedCorrectAnswers, + const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo); + const overloadedAnswers = record.correctAnswers; + if (overloadedAnswers && typeof overloadedAnswers === 'object') { + record.correctAnswerMap = Object.assign( + {}, + clone(asObject(overloadedAnswers)), + clone(asObject(record.correctAnswerMap)) + ); + } + const correct = firstNonNegative( + overloadedAnswers, record.correctAnswersCount, - record.correctCount, scoreInfo.correctAnswers, scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct, - rawScoreInfo.correctAnswers, - rawScoreInfo.correct + legacyScoreInfo.correctAnswers, + legacyScoreInfo.correct ); - if (correctAnswers === null) { - const comparisons = asArray(record.answerComparison).length - ? asArray(record.answerComparison) - : asArray(asObject(record.realData).answerComparison); - if (comparisons.length) { - correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; - } - } - if (correctAnswers !== null) record.correctAnswers = correctAnswers; - else if (isAnswerMap) record.correctAnswers = 0; - - const totalQuestions = firstNonNegativeScalar( + if (correct !== null) record.correctAnswers = correct; + else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0; + const total = firstNonNegative( record.totalQuestions, record.questionCount, scoreInfo.totalQuestions, scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total, - rawScoreInfo.totalQuestions, - rawScoreInfo.total + legacyScoreInfo.totalQuestions, + legacyScoreInfo.total ); - if (totalQuestions !== null) record.totalQuestions = totalQuestions; + if (total !== null) record.totalQuestions = total; + } + + function mergeAnswers(target, source) { + if (Array.isArray(source)) { + source.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1); + const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer; + if (answer !== undefined) target[questionId] = clone(answer); + }); + return; + } + for (const [questionId, answer] of Object.entries(asObject(source))) { + target[String(questionId)] = clone(answer); + } + } + + function normalizePracticeAnswers(record) { + const answers = {}; + const raw = asObject(record.rawData); + const rawReal = asObject(raw.realData); + const real = asObject(record.realData); + for (const source of [ + rawReal.answerMap, rawReal.answerList, rawReal.answers, + raw.answerMap, raw.answerList, raw.answers, + real.answerMap, real.answerList, real.answers, + record.answerMap, record.answerList, record.answers + ]) mergeAnswers(answers, source); + if (Object.keys(answers).length) record.answers = answers; + } + + function questionTypeErrorCounts(source) { + const counts = {}; + const add = (type, count = 1) => { + const key = String(type || '').trim(); + if (key && count > 0) counts[key] = (counts[key] || 0) + count; + }; + for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) { + const metrics = asObject(value); + const total = firstNonNegative(metrics.totalQuestions, metrics.total); + const correct = firstNonNegative(metrics.correctAnswers, metrics.correct); + if (total !== null && correct !== null) add(type, Math.max(0, total - correct)); + } + for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) { + if (detail && detail.isCorrect === false) add(detail.questionType || detail.type); + } + return counts; } function canonicalizeRecord(input) { @@ -1585,8 +1535,8 @@ record.metadata = asObject(record.metadata); if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; - canonicalizeAnswerSource(record); - normalizePracticeScoreFields(record); + normalizePracticeAnswers(record); + normalizePracticeScore(record); for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { if (record[field] === undefined || record[field] === null || record[field] === '') continue; const numeric = Number(record[field]); @@ -1597,97 +1547,13 @@ return jsonValue(record, 'canonical practice record'); } - function deriveQuestionTypeErrorCounts(source) { - const record = asObject(source); - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const counts = {}; - const addCount = (type, value) => { - const key = String(type || 'other').trim() || 'other'; - const amount = Math.max(0, Number(value) || 0); - if (amount > 0) counts[key] = (counts[key] || 0) + amount; - }; - const performanceSources = [ - record.questionTypePerformance, - realData.questionTypePerformance, - rawData.questionTypePerformance - ]; - let hasPerformanceData = false; - for (const performanceMap of performanceSources) { - if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; - let sourceHasPerformanceData = false; - for (const [type, value] of Object.entries(performanceMap)) { - const performance = asObject(value); - const total = Number(performance.total ?? performance.totalQuestions); - const correct = Number(performance.correct ?? performance.correctAnswers); - if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; - hasPerformanceData = true; - sourceHasPerformanceData = true; - addCount(type, total - correct); - } - if (sourceHasPerformanceData) break; - } - if (hasPerformanceData) return counts; - - const questionTypeMap = Object.assign( - {}, - asObject(rawData.questionTypeMap), - asObject(realData.questionTypeMap), - asObject(record.questionTypeMap) - ); - const normalizedTypeMap = {}; - for (const [questionId, type] of Object.entries(questionTypeMap)) { - normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; - } - const detailSources = [ - record.answerDetails, - asObject(record.scoreInfo).details, - realData.answerDetails, - asObject(realData.scoreInfo).details, - rawData.answerDetails, - asObject(rawData.scoreInfo).details - ]; - const seenQuestions = new Set(); - for (const details of detailSources) { - if (!details || typeof details !== 'object' || Array.isArray(details)) continue; - for (const [questionId, value] of Object.entries(details)) { - const detail = asObject(value); - const normalizedId = String(questionId).trim().toLowerCase(); - if (!normalizedId || seenQuestions.has(normalizedId)) continue; - let isWrong = detail.isCorrect === false || detail.correct === false; - if (detail.isCorrect === true || detail.correct === true) isWrong = false; - else if (!isWrong) { - const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); - const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); - isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); - } - if (!isWrong) continue; - seenQuestions.add(normalizedId); - addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); - } - } - return counts; - } - function lightSuiteEntry(source, fallbackType = null) { const entry = asObject(source); const scoreInfo = asObject(entry.scoreInfo); const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); const metadata = asObject(entry.metadata); - const totalQuestions = firstNonNegativeScalar( - entry.totalQuestions, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total - ) ?? 0; - const correctAnswers = firstNonNegativeScalar( - entry.correctAnswers, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct - ) ?? 0; + const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0; + const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0; const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; const accuracy = normalizeAccuracyRatio( explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), @@ -1699,14 +1565,14 @@ sessionId: entry.sessionId || null, examId: entry.examId || metadata.examId || null, title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', - type: entry.type || metadata.type || metadata.examType || fallbackType || null, + type: entry.type || metadata.type || fallbackType, date: entry.date || entry.completedAt || entry.timestamp || null, duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, totalQuestions, correctAnswers, accuracy, percentage, - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + questionTypeErrorCounts: questionTypeErrorCounts(entry) }, 'suite entry light projection'); } @@ -1743,6 +1609,7 @@ accuracy, percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + questionTypeErrorCounts: questionTypeErrorCounts(source), // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 @@ -1756,8 +1623,10 @@ 'dataSource', 'source', 'libraryConfigurationId' ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), suite: source.suite == null ? null : clone(asObject(source.suite)), - suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry( + entry, + String(source.type || '').replace(/-suite$/, '') || null + )) }, 'practice light projection'); } @@ -1778,7 +1647,7 @@ return first === undefined ? {} : clone(first); } - const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']); const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); function withoutRawData(value) { @@ -1797,11 +1666,10 @@ const detail = { recordId: source.id }; const annotations = { recordId: source.id }; for (const [key, value] of Object.entries(source)) { - if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue; if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { const next = Object.assign({}, asObject(entry)); - canonicalizeAnswerSource(next); const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); @@ -1823,7 +1691,7 @@ } // Accept the old mirror only as an input normalization boundary; it is never persisted. const realData = asObject(source.realData); const rawData = asObject(source.rawData); - for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); } for (const key of ANNOTATION_FIELDS) { @@ -2036,8 +1904,6 @@ // Entity records are authoritative. Projections are assembled on reads, never cached or // scheduled as follow-up work; this keeps a successful write immediately observable. - async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } - async function retryMergeConflict(options, task, maxAttempts = 3) { const explicitRevision = hasOwn(options, 'expectedRevision'); let lastError; @@ -2137,51 +2003,10 @@ function practiceLayerId(row) { return String(row && (row.recordId || row.id || row.sessionId) || ''); } - async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { - // The real kernel reads all three entity stores in one readonly - // IndexedDB transaction. Keep the fallback for deliberately minimal - // embedders and unit-test kernels that only expose the original methods. - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); - } - const ids = recordIds === null || recordIds === undefined - ? null - : (Array.isArray(recordIds) ? recordIds : [recordIds]) - .map((value) => String(value || '')) - .filter(Boolean); - const summaries = ids === null - ? await kernel.listEntities('practiceSummaries', { withMeta }) - : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); - const targetIds = summaries.map(practiceLayerId).filter(Boolean); - const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; - const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) - .then((rows) => rows.filter(Boolean)); - return { - practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], - practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], - practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] - }; - } async function practiceLayers(recordId, withMeta = false) { - const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta }); const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; - return { - summary: find('practiceSummaries'), - detail: find('practiceDetails'), - annotations: find('practiceAnnotations') - }; - } - async function suiteChildRecordIds(command, aggregateRecordId) { - const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); - const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); - if (sessionIds.size) { - const summaries = await kernel.listEntities('practiceSummaries'); - for (const summary of summaries) { - if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); - } - } - ids.delete(String(aggregateRecordId)); - return ids; + return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') }; } function entityRevision(row) { return row ? Number(row.revision) : 0; } function practiceUpserts(recordId, layers, existing = {}) { @@ -2193,76 +2018,30 @@ } async function joinedPractice(recordId, projection, snapshot = null) { const mode = String(projection || 'full').toLowerCase(); - const layers = snapshot || await practiceProjectionSnapshot([recordId], false, - mode === 'light' || mode === 'summary' - ? ['practiceSummaries'] - : (mode === 'detail' || mode === 'medium' - ? ['practiceSummaries', 'practiceDetails'] - : null)); - const summary = asArray(layers.practiceSummaries) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const stores = mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined); + const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores }); + const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + const summary = find('practiceSummaries'); if (!summary) return null; if (mode === 'light' || mode === 'summary') return clone(summary); - const detail = asArray(layers.practiceDetails) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const detail = find('practiceDetails'); if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); - const annotations = asArray(layers.practiceAnnotations) - .find((row) => practiceLayerId(row) === String(recordId)) || null; - return joinPracticeRecord(summary, detail, annotations, mode); - } - function practiceSummaryTime(summary) { - const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); - return Number.isFinite(time) ? time : 0; - } - function isReadingInsightSummary(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => practiceType(entry) === 'reading'); - } - return practiceType(asObject(summary)) === 'reading'; - } - function needsQuestionTypeInsightBackfill(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => - practiceType(entry) === 'reading' - && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); - } - return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode); } const practice = Object.freeze({ async list(options = {}) { await ready; const projection = String(options.projection || 'full').toLowerCase(); - const snapshot = await practiceProjectionSnapshot(null, false, - projection === 'light' || projection === 'summary' - ? ['practiceSummaries'] - : null); - const summaries = asArray(snapshot.practiceSummaries); + const summaries = await kernel.listEntities('practiceSummaries'); if (projection === 'light' || projection === 'summary') return summaries; - return (await Promise.all(summaries - .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) - .filter(Boolean); - }, - async listInsights(options = {}) { - await ready; - const requestedLimit = Number(options.limit); - const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 - ? Math.min(requestedLimit, 50) - : 10; - const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); - const summaries = asArray(snapshot.practiceSummaries) - .filter(isReadingInsightSummary) - .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) - .slice(0, limit); - return summaries.map((summary) => { - if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); - const detail = asArray(snapshot.practiceDetails) - .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; - return detail - ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) - : clone(summary); - }); + const stores = projection === 'detail' || projection === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : undefined; + const snapshot = await kernel.readPracticeSnapshot(null, { stores }); + return (await Promise.all(asArray(snapshot.practiceSummaries) + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean); }, async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, async completeAttempt(command) { @@ -2282,9 +2061,13 @@ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String); + const children = new Set((await kernel.listEntities('practiceSummaries')) + .filter((summary) => practiceRecordMatches(summary, childIdentities)) + .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId']))); + children.delete(recordId); const receipt = await retryMergeConflict(command, async () => { const existing = await practiceLayers(recordId, true); - const children = await suiteChildRecordIds(command, recordId); const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); }); @@ -2327,6 +2110,22 @@ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); }, async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async listInsights(options = {}) { + await ready; + const limit = Math.max(1, Math.min(50, Number(options.limit) || 10)); + const summaries = (await kernel.listEntities('practiceSummaries')) + .slice() + .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '') + .localeCompare(String(left.date || left.completedAt || left.timestamp || ''))) + .slice(0, limit); + return Promise.all(summaries.map(async (summary) => { + if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary); + const detail = await kernel.readEntity('practiceDetails', summary.id); + return jsonValue(Object.assign({}, clone(summary), { + questionTypeErrorCounts: questionTypeErrorCounts(detail) + }), 'practice insight'); + })); + }, async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, projectLight, projectDetail @@ -2574,88 +2373,6 @@ 'library.activeConfigurationId' ]); - function parseLegacyImportValue(value) { - if (typeof internals.parseLegacyValue !== 'function') { - throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); - } - return internals.parseLegacyValue(value); - } - - function decodePoisonedDocument(logicalKey, wrapped) { - const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; - if (!aliases || !isPlainImportObject(wrapped) - || !Object.prototype.hasOwnProperty.call(wrapped, 'key') - || !Object.prototype.hasOwnProperty.call(wrapped, 'value') - || !aliases.includes(String(wrapped.key))) { - return { matched: false, value: null }; - } - const decoded = parseLegacyImportValue(wrapped.value); - if (!isPlainImportObject(decoded)) return { matched: true, value: null }; - const overlay = {}; - for (const [key, value] of Object.entries(wrapped)) { - if (key === 'key' || key === 'value' || key === 'timestamp') continue; - overlay[key] = clone(value); - } - return { - matched: true, - value: Object.assign({}, decoded, overlay) - }; - } - - function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { - if (!envelope || envelope.state !== 'present') return envelope; - const wrapped = envelope.data; - const decoded = decodePoisonedDocument(logicalKey, wrapped); - if (!decoded.matched || !decoded.value) return envelope; - const entry = catalog.get(logicalKey); - const next = internals.makeEnvelope(entry, decoded.value, { - revision: Number(envelope.revision) || 1, - operationId: String(envelope.operationId || randomId('import-repair')), - updatedAt: envelope.updatedAt - }); - repairedKeys.push(logicalKey); - warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); - return next; - } - - function validateLibraryImportBundle(envelopes) { - const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); - if (!presentKeys.length) return { valid: true, presentKeys }; - if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { - return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; - } - const values = {}; - for (const key of LIBRARY_IMPORT_KEYS) { - const envelope = envelopes[key]; - values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; - } - const configurations = asArray(values['library.configurations']); - const indexes = asObject(values['library.importedIndexes']); - const configurationIds = new Set(); - for (const configuration of configurations) { - const source = asObject(configuration); - const id = idOf(source, ['id', 'key', 'configId']); - if (!id || !acceptedLibraryId(id) || source.builtIn === true - || !Array.isArray(indexes[id]) || !indexes[id].length) { - return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; - } - configurationIds.add(id); - } - for (const [id, index] of Object.entries(indexes)) { - if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { - return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; - } - } - const activeId = values['library.activeConfigurationId']; - if (activeId !== null && (!acceptedLibraryId(activeId) - || !configurationIds.has(String(activeId)) - || !Array.isArray(indexes[String(activeId)]) - || !indexes[String(activeId)].length)) { - return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; - } - return { valid: true, presentKeys }; - } - function canonicalizeV2Import(parsed) { const warnings = []; const repairedKeys = []; @@ -2663,41 +2380,43 @@ const envelopes = {}; for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); - if (logicalKey === 'library.activeConfigurationId' - && rawEnvelope && rawEnvelope.state === 'present' - && String(rawEnvelope.data) === '[object Object]') { + const envelope = clone(rawEnvelope); + const data = envelope && envelope.state === 'present' ? envelope.data : null; + if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') { ignoredKeys.push(logicalKey); warnings.push('Skipped poisoned active library id'); continue; } - const repairCount = repairedKeys.length; - const repaired = repairPoisonedImportEnvelope( - logicalKey, - clone(rawEnvelope), - repairedKeys, - warnings - ); - const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; - const isLegacyRowWrapper = isPlainImportObject(rawData) - && Object.prototype.hasOwnProperty.call(rawData, 'key') - && Object.prototype.hasOwnProperty.call(rawData, 'value') - && String(rawData.key || '').startsWith('exam_system_'); - if (isLegacyRowWrapper && repairedKeys.length === repairCount) { - ignoredKeys.push(logicalKey); - warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); - continue; - } - envelopes[logicalKey] = repaired; - } - const library = parsed.scope === 'full' - ? validateLibraryImportBundle(envelopes) - : { valid: true, presentKeys: [] }; - if (!library.valid) { - for (const logicalKey of library.presentKeys) { - delete envelopes[logicalKey]; - ignoredKeys.push(logicalKey); + if (isPlainImportObject(data) + && Object.prototype.hasOwnProperty.call(data, 'key') + && Object.prototype.hasOwnProperty.call(data, 'value') + && String(data.key || '').startsWith('exam_system_')) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || []; + const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null; + if (!isPlainImportObject(decoded)) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + const overlay = Object.fromEntries(Object.entries(data) + .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp')); + envelope.data = Object.assign({}, decoded, overlay); + envelope.checksum = checksum(envelope.data); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); + } + envelopes[logicalKey] = envelope; + } + + if (parsed.scope === 'full') { + const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) { + for (const key of presentLibraryKeys) { + delete envelopes[key]; + ignoredKeys.push(key); + } + warnings.push('Skipped incomplete library data'); } - warnings.push(`Skipped unsafe library data: ${library.reason}`); } const exportableKeys = catalog.list() .filter((entry) => entry.export === true && isImportableEntry(entry)) @@ -2705,9 +2424,7 @@ const missingKeys = parsed.scope === 'full' ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) : []; - if (parsed.scope === 'full' && missingKeys.length) { - warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); - } + const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length); return { envelopes, warnings, @@ -2715,10 +2432,8 @@ ignoredKeys, missingKeys, declaredScope: parsed.scope, - effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, - trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length - ? 'trusted-full' - : 'degraded-partial' + effectiveScope: degraded ? 'partial' : parsed.scope, + trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial') }; } @@ -2979,9 +2694,6 @@ } async function currentEntitySnapshot() { - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(null, { withMeta: true }); - } const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); const result = {}; for (const store of PRACTICE_ENTITY_STORES) { @@ -3018,19 +2730,31 @@ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); continue; } - const currentRead = await kernel.read(logicalKey, { withMeta: true }); - const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; - const currentEnvelope = currentRead && currentRead.envelope; - revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + const current = await kernel.read(logicalKey, { withMeta: true }); + revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0; let next = envelope; if (!replaceDocuments && envelope.state === 'present') { - next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') }); } snapshot.envelopes[logicalKey] = next; keys.push(logicalKey); if (next.state === 'cleared') clearedKeys.push(logicalKey); } + // A full replace mirrors all exportable user data. Missing physical + // envelopes mean catalog defaults, represented here as explicit clears. + if (replaceDocuments && parsed.scope === 'full') { + for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) { + if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue; + snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, { + state: 'cleared', + operationId: randomId('import-clear') + }); + keys.push(entry.logicalKey); + clearedKeys.push(entry.logicalKey); + } + } + // Any successful practice import installs all three stores together. Merge // may update a subset only when the final recordId sets remain identical. const sourceStores = Object.keys(asObject(parsed.entities)); @@ -3243,7 +2967,7 @@ const mutation = optionsMutationOptions(options, 'vocab-words', words); return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.words', { withMeta: true }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.words', data: words, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3285,7 +3009,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3300,7 +3024,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), upserts); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3327,7 +3051,7 @@ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); list.updatedAt = nowIso(); collections[id] = list; - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3345,7 +3069,7 @@ const current = await kernel.read('vocab.lists', { withMeta: true }); const collections = Object.assign({}, asObject(current.data)); collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3411,7 +3135,7 @@ { id: listId, words: merged, updatedAt: nowIso() } ) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -3444,7 +3168,7 @@ : Object.assign({}, collections, { [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) @@ -3471,7 +3195,7 @@ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - return mutateAndProject(changes, mutation); + return kernel.mutate(changes, mutation); }); } }); @@ -3606,253 +3330,95 @@ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], 'achievements.manual': ['achievement_manual_state', 'user_achievements'] }); - const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ - 'recovery.activeSessions', - 'recovery.drafts', - 'recovery.interrupted', - 'recovery.rejectedCompletions' - ]); const LEGACY_PREFERENCE_ALIASES = Object.freeze({ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' }); - function legacyRecordArray(value) { - return Array.isArray(value) ? value : asArray(asObject(value).data); - } - function mergeLegacyExternalBackup(legacyValue, externalValue) { - const legacy = Object.assign({}, asObject(legacyValue)); + function mergeLegacySources(indexedDbValue, externalValue) { + const indexedDb = asObject(indexedDbValue); const external = asObject(externalValue); - const externalRecordKey = ['practice_records', 'practiceRecords'] - .find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (externalRecordKey) { - const records = new Map(); - for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { - const recordId = idOf(record, ['id', 'recordId', 'sessionId']); - records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); - } - legacy.practice_records = Array.from(records.values()); - } - for (const [target, aliases] of Object.entries({ - user_stats: ['user_stats', 'userStats'], - exam_index: ['exam_index', 'examIndex'], - storage_version: ['storage_version', 'storageVersion'] - })) { - if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; - const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (alias) legacy[target] = clone(external[alias]); - } - return legacy; - } - - function acceptedLibraryId(value) { - const id = value === null || value === undefined ? '' : String(value).trim(); - if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; - try { return importedLibraryId(id); } catch (_) { return null; } - } - - function remapLegacyLibraryId(value) { - return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + const merged = Object.assign({}, external, indexedDb); + const records = new Map(); + const addRecords = (value) => { + const list = Array.isArray(value) ? value : asArray(asObject(value).data); + list.forEach((record) => { + const id = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record)); + }); + }; + addRecords(external.practice_records || external.practiceRecords); + addRecords(indexedDb.practice_records); + if (records.size) merged.practice_records = Array.from(records.values()); + return merged; } - async function migrateLegacyLibraryData(legacy) { - const [configMeta, indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.configurations', { withMeta: true }), - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const legacyIdMap = new Map(); + function legacyLibraryBundle(legacy) { + const idMap = new Map(); const indexes = {}; - const addLegacyIndex = (oldId, value) => { - if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; - const index = asArray(value); - if (!index.length) return; - const mappedId = remapLegacyLibraryId(oldId); - legacyIdMap.set(oldId, mappedId); - indexes[mappedId] = clone(index); - }; - - for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); - // Reconciliation is a union. A healthy current v2 value wins for the same - // deterministic library ID, while missing legacy libraries are restored. - for (const [id, value] of Object.entries(asObject(indexMeta.data))) { - if (/^exam_index_/.test(id)) addLegacyIndex(id, value); - else { - const acceptedId = acceptedLibraryId(id); - if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); - } + for (const [oldId, value] of Object.entries(asObject(legacy))) { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue; + const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`; + idMap.set(oldId, id); + indexes[id] = clone(value); } - + if (!idMap.size) return null; const configurations = new Map(); - const addConfiguration = (configuration) => { - const source = asObject(configuration); - const oldId = idOf(source, ['id', 'key', 'configId']); - if (!oldId || oldId === 'exam_index') return; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - if (!id || !asArray(indexes[id]).length) return; - configurations.set(id, Object.assign({}, clone(source), { + asArray(legacy.exam_index_configurations).forEach((configuration) => { + const oldId = idOf(configuration, ['id', 'key', 'configId']); + const id = idMap.get(oldId); + if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length })); + }); + for (const [oldId, id] of idMap) { + if (!configurations.has(id)) configurations.set(id, { id, key: id, - examCount: indexes[id].length - })); - }; - asArray(legacy.exam_index_configurations).forEach(addConfiguration); - asArray(configMeta.data).forEach(addConfiguration); - for (const [oldId, id] of legacyIdMap) { - if (!configurations.has(id)) { - configurations.set(id, { - id, - key: id, - name: `迁移的自定义题库 (${oldId})`, - examCount: indexes[id].length, - sourceType: 'legacy-import' - }); - } - } - - const resolveActive = (value) => { - const oldId = value === null || value === undefined ? '' : String(value).trim(); - if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - return id && asArray(indexes[id]).length ? id : null; - }; - const currentRawActive = activeMeta.data; - let activeId = resolveActive(currentRawActive); - const currentIsExplicitDefault = Boolean(activeMeta.envelope) - && (currentRawActive === null || String(currentRawActive || '').trim() === ''); - if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { - activeId = resolveActive(legacy.active_exam_index_key); - } - - const nextConfigurations = Array.from(configurations.values()); - const changes = []; - if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { - changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); - } - if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { - changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); - } - if (activeId !== activeMeta.data) { - changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); - } - if (changes.length) { - await kernel.mutate(changes, { - operationId: `legacy-library-repair-v2-${checksum(changes)}` + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' }); } + return { + configurations: Array.from(configurations.values()), + indexes, + activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null + }; } async function migrateLegacyData() { // Unit embedders may provide a deliberately minimal kernel bootstrap. if (typeof internals.readLegacyValues !== 'function') return; - const legacySource = await internals.readLegacyValues(); - if (legacySource && legacySource.__legacyReadComplete === false) { - throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); - } const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); const migrationState = asObject(migrationMeta.data); + const v1Complete = asObject(migrationState.v1ToV2).status === 'complete'; + const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed'; let externalBackup = null; - if (asObject(migrationState.externalBackupV1).status !== 'consumed' - && typeof internals.readLegacyExternalBackup === 'function') { - try { - externalBackup = await internals.readLegacyExternalBackup(); - } catch (error) { + if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') { + try { externalBackup = await internals.readLegacyExternalBackup(); } + catch (error) { if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } - const legacy = externalBackup - ? mergeLegacyExternalBackup(legacySource, externalBackup) - : legacySource; - if (!legacy || !Object.keys(legacy).length) return; + if (v1Complete && !externalBackup) return; - const documentMetas = {}; - for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { - documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); - } - const [indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const documentRepairs = []; - const poisonedDocumentKeys = []; - for (const [logicalKey, current] of Object.entries(documentMetas)) { - if (!current.envelope || current.envelope.state !== 'present') continue; - const decoded = decodePoisonedDocument(logicalKey, current.data); - if (!decoded.matched) continue; - poisonedDocumentKeys.push(logicalKey); - const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; - const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - const legacyValue = legacyAlias ? legacy[legacyAlias] : null; - let repairValue = decoded.value; - if (isPlainImportObject(legacyValue)) { - repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); - } - if (!repairValue) continue; - documentRepairs.push({ - logicalKey, - data: repairValue, - expectedRevision: Number(current.envelope.revision) - }); - } - const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => - isPlainImportObject(value) - && Object.prototype.hasOwnProperty.call(value, 'key') - && Object.prototype.hasOwnProperty.call(value, 'value') - && /^exam_system_exam_index_/.test(String(value.key || ''))); - const poisonedActive = String(activeMeta.data) === '[object Object]'; - const libraryPoisoned = poisonedIndex || poisonedActive; - const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; - - await migrateLegacyLibraryData(legacy); - if (documentRepairs.length) { - await kernel.mutate(documentRepairs, { - operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` - }); + const indexedDb = await internals.readLegacyValues(); + if (indexedDb && indexedDb.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); } - + const legacy = mergeLegacySources(indexedDb, externalBackup); const changes = []; for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { - const currentAudit = asObject(migrationState.v1ToV2); - if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { - continue; - } const current = await kernel.getEnvelope(logicalKey); + if (current) continue; const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - if (!alias) continue; - const legacyValue = legacy[alias]; - if (!current) { - changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); - continue; - } - const isBadMigrationWrite = current.state === 'present' - && /^legacy-documents-/.test(String(current.operationId || '')); - if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { - changes.push({ - logicalKey, - data: legacyValue, - expectedRevision: Number(current.revision) - }); - continue; - } - const entry = catalog.get(logicalKey); - if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; - let merged; - try { - merged = mergeImportValue(entry, legacyValue, current.data); - } catch (error) { - if (global.console && console.warn) { - console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); - } - continue; - } - if (checksum(merged) !== checksum(current.data)) { - changes.push({ - logicalKey, - data: merged, - expectedRevision: Number(current.revision) - }); - } + if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 }); + } + const libraryBundle = legacyLibraryBundle(legacy); + if (libraryBundle) { + if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 }); } if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { const preferences = {}; @@ -3867,84 +3433,52 @@ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); } - if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` }); const recordsValue = legacy.practice_records; const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); const operations = []; - let skippedRecords = 0; - const reconciledRecordIds = new Set(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; - let canonical; - let parts; + for (const [index, record] of records.entries()) { try { - const candidate = jsonValue(record, 'legacy practice record'); - if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { - candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const candidate = clone(record); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const canonical = canonicalizeRecord(candidate); + const parts = splitPracticeRecord(canonical); + for (const [store, data] of [ + ['practiceSummaries', parts.summary], + ['practiceDetails', parts.detail], + ['practiceAnnotations', parts.annotations] + ]) { + if (!await kernel.readEntity(store, canonical.id)) { + operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 }); + } } - canonical = canonicalizeRecord(candidate); - parts = splitPracticeRecord(canonical); } catch (error) { - skippedRecords += 1; if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); - continue; } - if (reconciledRecordIds.has(canonical.id)) continue; - reconciledRecordIds.add(canonical.id); - // Storage errors are not malformed records. Let them abort this repair so the - // completion marker is not written and the next startup can retry safely. - const existing = await practiceLayers(canonical.id, true); - if (existing.summary && existing.detail && existing.annotations) continue; - operations.push(...practiceUpserts(canonical.id, parts, existing)); } if (operations.length) { - await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` }); } - const migrationAudit = { - version: 4, + + const nextMigrationState = Object.assign({}, migrationState); + if (!v1Complete) nextMigrationState.v1ToV2 = { + version: 1, status: 'complete', - mode: 'persistent-reconcile', - sourceChecksum: checksum(legacy), - sourceRecordCount: records.length, - skippedRecordCount: skippedRecords - }; - const currentAudit = asObject(migrationState.v1ToV2); - const comparableCurrentAudit = { - version: currentAudit.version, - status: currentAudit.status, - mode: currentAudit.mode, - sourceChecksum: currentAudit.sourceChecksum, - sourceRecordCount: currentAudit.sourceRecordCount, - skippedRecordCount: currentAudit.skippedRecordCount + completedAt: nowIso(), + sourceChecksum: checksum(indexedDb), + sourceRecordCount: asArray(indexedDb.practice_records).length }; - const externalAudit = externalBackup ? { + if (externalBackup) nextMigrationState.externalBackupV1 = { version: 1, status: 'consumed', + completedAt: nowIso(), sourceChecksum: checksum(externalBackup) - } : null; - const currentExternalAudit = asObject(migrationState.externalBackupV1); - if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) - || (externalAudit && (currentExternalAudit.status !== externalAudit.status - || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { - const nextMigrationState = Object.assign({}, migrationState, { - v1ToV2: Object.assign({}, migrationAudit, { - completedAt: nowIso(), - poisonDetected, - poisonedDocumentKeys, - libraryPoisoned - }) - }); - if (externalAudit) { - nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); - } - await kernel.mutate([{ - logicalKey: 'system.migrations', - data: nextMigrationState, - expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 - }], { - operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` - }); - } + }; + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` }); } const ready = kernel.initialize() @@ -13217,22 +12751,6 @@ attachDragDrop(); attachPaneResizer(); - // Ensure drag items can return home when replaced or discarded - function initDragPools() { - document.querySelectorAll('.pool-items').forEach((pool, index) => { - if (!pool.id) { - pool.id = `practice-pool-${index}`; - } - }); - document.querySelectorAll('.pool-items .drag-item').forEach((item) => { - if (!item.dataset.originPool) { - const pool = item.closest('.pool-items'); - if (pool?.id) { - item.dataset.originPool = pool.id; - } - } - }); - } initDragPools(); attachUnifiedTimer(); diff --git a/js/core/siteDataReset.js b/js/core/siteDataReset.js index 48734f9d..ae197aa1 100644 --- a/js/core/siteDataReset.js +++ b/js/core/siteDataReset.js @@ -1,675 +1,125 @@ -/** - * Destructive browser-site reset. - * - * This path deliberately bypasses AppData domain mutations. A reset must not - * append operation journals, rebuild projectors, or flush an empty snapshot to - * the bound external backup folder. - */ +/** Clear all browser-local IELTS Atlas data while preserving external JSON files. */ (function initSiteDataReset(global) { 'use strict'; if (global.SiteDataReset && global.SiteDataReset.__v2 === true) { - if (typeof global.clearCache !== 'function') { - global.clearCache = global.SiteDataReset.request; - } + global.clearCache = global.SiteDataReset.request; return; } - var DATABASE_NAMES = Object.freeze([ + const DATABASE_NAMES = Object.freeze([ 'IELTSAtlasDataV2', 'ExamSystemDB', 'IELTSAtlasExternalBackupV2' ]); - /** - * How long a `blocked` deletion is allowed to keep waiting before it is - * reported as a failure. - * - * A cooperative peer (data kernel connections install `onversionchange` and - * close immediately) releases the database within a tick, while a peer that - * is in the middle of a long write can legitimately hold it for a few - * seconds. Waiting far beyond that only makes an unrecoverable block look - * like a frozen UI, and every database is deleted in parallel, so this is - * the worst case for the whole reset rather than a per-database cost. - */ - var BLOCKED_DELETE_TIMEOUT_MS = 8000; - var EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS = 8000; - /** - * `IDBFactory.deleteDatabase()` has no `abort()`. Once the blocked timeout - * wins, the browser keeps the request armed and will drop the database the - * moment the peer connection closes — possibly minutes later, possibly after - * this page reloaded and started using a freshly created database. - * - * `pendingDeletions` is that un-cancellable tail: a database name stays here - * from the moment we give up waiting until the browser actually reports the - * request as done. While a name is listed the reset is "armed but not - * finished", which is a materially different state from both "succeeded" and - * "failed" and must be surfaced as such. - */ - var pendingDeletions = new Map(); - var pendingDeletionSequence = 0; - /** - * Cross-refresh recovery marker. - * - * A reloaded page cannot observe the previous page's `IDBRequest` — that - * object died with the old realm — so the in-memory registry above is lost on - * every reload. The marker carries the *fact* that a reset is still armed - * across the reload so the new page can tell the user the truth instead of - * looking pristine. - * - * It never re-arms a delete by itself. A later realm must obtain explicit - * recovery confirmation before it may queue a replacement deletion. - */ - var PENDING_DELETION_MARKER_KEY = 'ielts_atlas:v2:site-reset:pending-deletions'; - var WINDOW_NAME_MARKER_PREFIX = '__IELTS_ATLAS_SITE_RESET__:'; - /** - * The marker is written *after* `clearWebStorage()` (it would be wiped - * otherwise), which means it is the one key that survives a "clear - * everything" run. Age is used to strengthen the recovery warning, not to - * guess that the underlying request completed. Explicit recovery confirmation - * is the bounded escape hatch for a marker whose old realm is gone forever. - */ - var PENDING_DELETION_MARKER_TTL_MS = 600000; - var adoptedPendingDatabases = []; - var adoptedPendingMarkerState = null; - var resetPromise = null; - - function nowMs() { - try { - if (typeof Date === 'function' && typeof Date.now === 'function') return Date.now(); - } catch (_) { /* exotic host */ } - return 0; - } - - function notify(message, type) { - if (typeof global.showMessage === 'function') { - global.showMessage(message, type || 'info'); - } else if (global.console && typeof global.console.log === 'function') { - global.console.log('[SiteDataReset] ' + message); - } - } + let resetPromise = null; - // Timers are looked up defensively: this module is also loaded inside test - // realms and worker-like hosts that do not expose the full window surface. - function hostSetTimeout(callback, delay) { - try { - if (global && typeof global.setTimeout === 'function') { - return { id: global.setTimeout(callback, delay), host: global }; - } - } catch (_) { /* fall through to the ambient timer */ } - if (typeof setTimeout === 'function') { - return { id: setTimeout(callback, delay), host: null }; + function notify(message, type = 'info') { + if (typeof global.showMessage === 'function') global.showMessage(message, type); + else if (global.console && typeof global.console.log === 'function') { + global.console.log(`[SiteDataReset] ${message}`); } - return null; - } - - function hostClearTimeout(handle) { - if (!handle) return null; - try { - if (handle.host && typeof handle.host.clearTimeout === 'function') { - handle.host.clearTimeout(handle.id); - return null; - } - } catch (_) { - return null; - } - if (typeof clearTimeout === 'function') clearTimeout(handle.id); - return null; - } - - function createBlockedError(name) { - var error = new Error( - '数据库被其他 IELTS Atlas 标签页占用,未能删除:' + name - + '(等待 ' + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒后放弃)。' - ); - error.code = 'DELETE_DATABASE_BLOCKED'; - error.blocked = true; - error.database = name; - error.timeoutMs = BLOCKED_DELETE_TIMEOUT_MS; - return error; - } - - function createQuiesceTimeoutError() { - var error = new Error( - '外部备份停止写入超时(等待 ' - + Math.round(EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS / 1000) + ' 秒)。' - ); - error.code = 'EXTERNAL_BACKUP_QUIESCE_TIMEOUT'; - error.timeoutMs = EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS; - return error; } - function readStorage(name) { - try { - var storage = global[name]; - if (storage && typeof storage.getItem === 'function') return storage; - } catch (_) { /* storage disabled by policy or a sandboxed frame */ } - return null; - } - - function markerStorages() { - return [readStorage('localStorage'), readStorage('sessionStorage')].filter(function (storage, index, all) { - return !!storage && all.indexOf(storage) === index; - }); - } - - function readWindowNameMarker() { - var value = ''; - try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return null; } - var parts = value.split('\n'); - for (var index = parts.length - 1; index >= 0; index -= 1) { - if (parts[index].indexOf(WINDOW_NAME_MARKER_PREFIX) === 0) { - return parts[index].slice(WINDOW_NAME_MARKER_PREFIX.length); - } - } - return null; - } - - function replaceWindowNameMarker(raw) { - var value = ''; - try { value = typeof global.name === 'string' ? global.name : ''; } catch (_) { return false; } - var retained = value.split('\n').filter(function (part) { - return part.indexOf(WINDOW_NAME_MARKER_PREFIX) !== 0; - }); - if (retained.length === 1 && retained[0] === '') retained = []; - if (raw) retained.push(WINDOW_NAME_MARKER_PREFIX + raw); - try { - global.name = retained.join('\n'); - return raw ? readWindowNameMarker() === raw : readWindowNameMarker() === null; - } catch (_) { - return false; - } - } - - /** - * Persist the recovery marker. Called only from the tail of `perform`, after - * `clearWebStorage()`, so the value is not immediately erased by the very - * reset that produced it. - */ - function writePendingDeletionMarker(names) { - if (!names || !names.length) { - clearPendingDeletionMarker(); - return true; - } - var value = JSON.stringify({ state: 'pending', databases: names.slice(), at: nowMs() }); - var persisted = false; - markerStorages().forEach(function (storage) { - if (typeof storage.setItem !== 'function') return; - try { - storage.setItem(PENDING_DELETION_MARKER_KEY, value); - persisted = storage.getItem(PENDING_DELETION_MARKER_KEY) === value || persisted; - } catch (_) { /* try the other storage */ } - }); - persisted = replaceWindowNameMarker(value) || persisted; - return persisted; - } - - function clearPendingDeletionMarker() { - markerStorages().forEach(function (storage) { - if (typeof storage.removeItem !== 'function') return; - try { - storage.removeItem(PENDING_DELETION_MARKER_KEY); - } catch (_) { /* best-effort */ } - }); - replaceWindowNameMarker(null); - } - - /** - * Read a marker left by a previous page load. - * - * Expired or malformed evidence cannot prove that the old request completed. - * Keep the page in a recoverable confirmation-required state instead of - * silently turning uncertainty into "safe". - */ - function readPendingDeletionMarker() { - var sawMarker = false; - var invalidMarker = false; - var validCandidate = null; - var rawMarkers = []; - markerStorages().forEach(function (storage) { - try { rawMarkers.push(storage.getItem(PENDING_DELETION_MARKER_KEY)); } catch (_) { /* unreadable */ } - }); - rawMarkers.push(readWindowNameMarker()); - rawMarkers.forEach(function (raw) { - if (!raw) return; - sawMarker = true; - var parsed = null; - try { parsed = JSON.parse(raw); } catch (_) { invalidMarker = true; return; } - var names = parsed && parsed.databases; - var state = parsed && parsed.state; - if (state && state !== 'pending' && state !== 'unknown') { - invalidMarker = true; - return; - } - if (!names || typeof names.length !== 'number' || !names.length) { - invalidMarker = true; + function deleteDatabase(name) { + return new Promise((resolve, reject) => { + const indexedDB = global.indexedDB; + if (!indexedDB || typeof indexedDB.deleteDatabase !== 'function') { + resolve({ name, skipped: true }); return; } - var adopted = []; - for (var index = 0; index < names.length; index += 1) { - if (DATABASE_NAMES.indexOf(names[index]) !== -1 && adopted.indexOf(names[index]) === -1) { - adopted.push(names[index]); - } - } - if (!adopted.length) { invalidMarker = true; return; } - var at = Number(parsed.at); - var age = nowMs() - (isFinite(at) ? at : 0); - validCandidate = { - databases: adopted, - state: 'unknown', - expired: !isFinite(at) || age < 0 || age > PENDING_DELETION_MARKER_TTL_MS - }; - }); - if (validCandidate) return validCandidate; - if (sawMarker || invalidMarker) { - return { databases: DATABASE_NAMES.slice(), state: 'unknown', corrupt: true }; - } - return { databases: [], state: 'retired' }; - } - - /** - * Register a deletion request we stopped waiting for, and keep watching it. - * - * The handlers installed here are intentionally *not* the ones `settle()` - * detached: those could still resolve the caller's promise and rewrite an - * outcome that has already been reported. These are pure observers — their - * only job is to notice that the un-cancellable request finally ran, so the - * pending state can be retired truthfully instead of by timeout. - */ - function trackPendingDeletion(name, request) { - pendingDeletionSequence += 1; - var token = pendingDeletionSequence; - pendingDeletions.set(name, { token: token, at: nowMs(), request: request }); - - function retire() { - var entry = pendingDeletions.get(name); - // A newer reset attempt may have replaced this entry; only the owner - // of the current token may retire it. - if (!entry || entry.token !== token) return; - pendingDeletions.delete(name); - var remaining = listLivePendingDeletions(); - if (remaining.length) { - writePendingDeletionMarker(remaining); - } else { - clearPendingDeletionMarker(); - } - } - try { - request.onsuccess = function () { retire(); }; - request.onerror = function () { retire(); }; - // A repeated `onblocked` means the peer is still holding on. Nothing - // to retire yet, but swallow it so it cannot reach a stale handler. - request.onblocked = function () { }; - } catch (_) { - // Read-only handlers are rare, but guessing completion would be - // unsafe. The entry therefore remains restricted until this realm is - // torn down and the cross-refresh recovery flow takes over. - } - return token; - } + let request; + try { request = indexedDB.deleteDatabase(name); } + catch (error) { reject(error); return; } - /** - * Live pending deletions: requests this realm issued and can still observe. - * - * Only these gate a new reset. An entry leaves this list the moment the - * browser reports the deletion done, so the common "close the other tab and - * retry" path unblocks immediately rather than waiting out a timer. - */ - function listLivePendingDeletions() { - var names = []; - pendingDeletions.forEach(function (_entry, name) { - if (names.indexOf(name) === -1) names.push(name); + request.onsuccess = () => resolve({ name, deleted: true }); + request.onerror = () => reject(request.error || new Error(`删除数据库失败:${name}`)); + request.onblocked = () => notify( + `数据库 ${name} 正被其他 IELTS Atlas 标签页占用。请关闭其他标签页,清理会自动继续。`, + 'warning' + ); }); - return names; } - /** Live plus adopted names — everything worth telling the user about. */ - function listPendingDeletions() { - var names = listLivePendingDeletions(); - for (var index = 0; index < adoptedPendingDatabases.length; index += 1) { - if (names.indexOf(adoptedPendingDatabases[index]) === -1) names.push(adoptedPendingDatabases[index]); + async function stopExternalBackup() { + const service = global.ExternalBackupService; + if (!service) return; + if (typeof service.prepareForFullReset === 'function') { + await service.prepareForFullReset(); + } else if (typeof service.unbindDirectory === 'function') { + await service.unbindDirectory(); } - return names; - } - - function currentDeletionState() { - if (listLivePendingDeletions().length) return 'pending'; - if (adoptedPendingDatabases.length) return 'unknown'; - return 'retired'; - } - - /** - * The reason a caller must not start a new reset right now, or null. - * - * Live requests only retire on their real terminal event. Cross-refresh - * evidence can be recovered from, but only after explicit confirmation; this - * avoids both an automatic false-safe state and a permanent marker lockout. - */ - function pendingDeletionBlock(options) { - var live = listLivePendingDeletions(); - var adopted = adoptedPendingDatabases.slice(); - if (!live.length && !adopted.length) return null; - var recoveryRequired = !live.length && adopted.length > 0 - && !(options && options.recoveryConfirmed === true); - if (!live.length && !recoveryRequired) return null; - return { - success: false, - reason: recoveryRequired ? 'recovery_confirmation_required' : 'deletion_pending', - deletionPending: true, - pendingDatabases: listPendingDeletions(), - retryable: true, - recoveryConfirmationRequired: recoveryRequired, - deletionState: currentDeletionState(), - markerExpired: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.expired), - markerCorrupt: !!(adoptedPendingMarkerState && adoptedPendingMarkerState.corrupt), - terminal: false, - databases: DATABASE_NAMES.slice(), - externalBackupFilesPreserved: true - }; - } - - function pendingDeletionMessage(names) { - return '上一次清理仍在等待其他 IELTS Atlas 标签页关闭:' + names.join('、') - + '。浏览器无法取消这个删除请求,它会在其他标签页关闭后自动执行;' - + '在那之前请不要录入新数据,否则可能被这次迟到的删除一并清掉。'; - } - - function settleWithTimeout(value, timeoutMs, createTimeoutError) { - return new Promise(function (resolve, reject) { - var settled = false; - var timer = hostSetTimeout(function () { - if (settled) return; - settled = true; - reject(createTimeoutError()); - }, timeoutMs); - if (!timer) { - reject(createTimeoutError()); - return; - } - Promise.resolve(value).then(function (result) { - if (settled) return; - settled = true; - hostClearTimeout(timer); - resolve(result); - }, function (error) { - if (settled) return; - settled = true; - hostClearTimeout(timer); - reject(error); - }); - }); - } - - function deleteDatabaseStrict(name) { - return new Promise(function (resolve, reject) { - var indexedDb; - try { - indexedDb = global.indexedDB || null; - } catch (_) { - indexedDb = null; - } - if (!indexedDb || typeof indexedDb.deleteDatabase !== 'function') { - resolve({ name: name, skipped: true }); - return; - } - - var request; - try { - request = indexedDb.deleteDatabase(name); - } catch (error) { - reject(error); - return; - } - - var settled = false; - var blockedTimer = null; - - function settle(complete, payload) { - if (settled) return; - settled = true; - blockedTimer = hostClearTimeout(blockedTimer); - // An IndexedDB deleteDatabase request cannot be aborted. When the - // blocked timeout wins, the browser keeps the request pending and - // will still drop the database once the other tab releases its - // connection. Detaching the handlers here stops a late event from - // rewriting an outcome the caller already acted on; the request is - // then handed to `trackPendingDeletion`, whose observer handlers do - // nothing but retire the pending state when the delete really runs. - try { - request.onsuccess = null; - request.onerror = null; - request.onblocked = null; - } catch (_) { /* exotic hosts may expose read-only handlers */ } - var abandoned = !!(payload && payload.code === 'DELETE_DATABASE_BLOCKED'); - if (abandoned) trackPendingDeletion(name, request); - complete(payload); - } - - request.onsuccess = function () { - settle(resolve, { name: name, deleted: true }); - }; - request.onerror = function () { - settle(reject, request.error || new Error('删除数据库失败:' + name)); - }; - request.onblocked = function () { - if (settled || blockedTimer) return; - notify( - '清理被其他 IELTS Atlas 标签页阻塞,请立即关闭其他标签页;' - + Math.round(BLOCKED_DELETE_TIMEOUT_MS / 1000) + ' 秒内未释放将中止本次清理。', - 'warning' - ); - blockedTimer = hostSetTimeout(function () { - settle(reject, createBlockedError(name)); - }, BLOCKED_DELETE_TIMEOUT_MS); - if (!blockedTimer) { - // No timer API at all: fail fast rather than wait forever. - settle(reject, createBlockedError(name)); - } - }; - }); } function clearWebStorage() { - var failures = []; - ['localStorage', 'sessionStorage'].forEach(function (name) { - var storage; - try { - storage = global[name]; - } catch (error) { - failures.push({ storage: name, error: error }); - return; - } - if (!storage || typeof storage.clear !== 'function') return; + const errors = []; + for (const name of ['localStorage', 'sessionStorage']) { try { - storage.clear(); + const storage = global[name]; + if (storage && typeof storage.clear === 'function') storage.clear(); } catch (error) { - failures.push({ storage: name, error: error }); + errors.push({ stage: 'clear-web-storage', storage: name, error }); } - }); - return failures; + } + return errors; } - function reloadTerminal(options) { - if (options && options.reload === false) return false; - if (global.location && typeof global.location.reload === 'function') { - global.location.reload(); - return true; - } - return false; + function reload(options) { + if (options.reload === false) return false; + if (!global.location || typeof global.location.reload !== 'function') return false; + global.location.reload(); + return true; } - async function perform(options) { - var opts = options || {}; + async function perform(options = {}) { if (resetPromise) return resetPromise; - // Refuse to queue a second un-cancellable deletion behind one that is - // still armed. Checked before the singleton is installed so the refusal - // is never cached as "the" result of a reset. - var blockedByPending = pendingDeletionBlock(opts); - if (blockedByPending) { - notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); - return blockedByPending; - } - resetPromise = (async function () { - var externalBackup = global.ExternalBackupService; - var errors = []; + resetPromise = (async () => { try { - if (externalBackup && typeof externalBackup.prepareForFullReset === 'function') { - await settleWithTimeout( - externalBackup.prepareForFullReset(), - EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, - createQuiesceTimeoutError - ); - } else if (externalBackup && typeof externalBackup.unbindDirectory === 'function') { - await settleWithTimeout( - externalBackup.unbindDirectory(), - EXTERNAL_BACKUP_QUIESCE_TIMEOUT_MS, - createQuiesceTimeoutError - ); - } + await stopExternalBackup(); } catch (error) { - errors.push({ stage: 'external-backup-quiesce', error: error }); - } - - var deletionResults = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabaseStrict)); - var blockedDatabases = []; - deletionResults.forEach(function (result, index) { - if (result.status !== 'rejected') return; - var reason = result.reason; - var isBlocked = !!(reason && reason.code === 'DELETE_DATABASE_BLOCKED'); - if (isBlocked) blockedDatabases.push(DATABASE_NAMES[index]); - errors.push({ - stage: isBlocked ? 'delete-database-blocked' : 'delete-database', - database: DATABASE_NAMES[index], - blocked: isBlocked, - error: reason - }); - }); - clearWebStorage().forEach(function (failure) { - errors.push({ - stage: 'clear-web-storage', - storage: failure.storage, - error: failure.error - }); - }); - - // Written after clearWebStorage() on purpose: the reset wipes every - // key, so a marker persisted any earlier would erase itself. This is - // the one key that legitimately survives a full reset, which is why - // it carries its own TTL. - // - // Adopted names are dropped unconditionally here. This run issued a - // fresh deleteDatabase() for every name, and the connection queue is - // FIFO per database: whatever a previous realm queued was necessarily - // processed ahead of the request we just awaited, so it is no longer - // outstanding regardless of how this run ended. - adoptedPendingDatabases = []; - adoptedPendingMarkerState = null; - var stillPending = listLivePendingDeletions(); - var markerPersisted = true; - if (stillPending.length) { - markerPersisted = writePendingDeletionMarker(stillPending); - if (!markerPersisted) { - errors.push({ - stage: 'pending-deletion-marker', - error: new Error('无法持久化仍在等待的数据库删除状态。') - }); - } - } else { - clearPendingDeletionMarker(); + notify('外部备份仍在写入,本次清理已取消。', 'error'); + return { + success: false, + reason: 'external_backup_busy', + terminal: false, + error, + databases: DATABASE_NAMES.slice(), + externalBackupFilesPreserved: true + }; } + const results = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabase)); + const errors = results.flatMap((result, index) => result.status === 'rejected' + ? [{ stage: 'delete-database', database: DATABASE_NAMES[index], error: result.reason }] + : []); + errors.push(...clearWebStorage()); if (errors.length) { - if (blockedDatabases.length) { - notify( - '清理未完成:' + blockedDatabases.join('、') - + ' 仍被其他 IELTS Atlas 标签页占用。浏览器无法取消该删除请求,' - + '它会在其他标签页关闭后自动执行。请关闭全部其他标签页(含练习/听力弹窗)后,' - + '等待当前页面确认删除完成后再重试,在此之前不要继续录入新数据。', - 'error' - ); - } else { - notify('本地数据仅部分清除,页面将刷新;请刷新后再次执行清理。', 'error'); - } - // Keep this realm alive while it owns observable delete requests. - // Reloading would discard the only truthful success/error observer. - var reloadedAfterFailure = stillPending.length ? false : reloadTerminal(opts); + notify('本地数据仅部分清除,请关闭其他标签页后重试。', 'error'); return { success: false, reason: 'partial_reset', - blocked: blockedDatabases.length > 0, - blockedDatabases: blockedDatabases, - deletionPending: stillPending.length > 0, - pendingDatabases: stillPending, - markerPersisted: markerPersisted, - deletionState: stillPending.length ? 'pending' : 'retired', - retryable: true, - // `terminal` means "this page was actually torn down". Callers - // use it to decide whether they still own a live document, so - // reporting a reload that never happened strands them on a - // page they believe is gone. - terminal: reloadedAfterFailure, - errors: errors, + terminal: false, + errors, databases: DATABASE_NAMES.slice(), externalBackupFilesPreserved: true }; } - var reloaded = reloadTerminal(opts); + return { success: true, - terminal: reloaded, - deletionState: 'retired', + terminal: reload(options), databases: DATABASE_NAMES.slice(), externalBackupFilesPreserved: true }; })(); - try { - var outcome = await resetPromise; - // The singleton exists only to collapse duplicate clicks on one - // in-flight run; it is not a result cache. Anything already settled - // must be released, or the next click replays a stale outcome without - // clearing a single byte. - // - // The one case worth keeping is a reset that really did call - // location.reload(): the document is being torn down, and holding the - // resolved promise suppresses clicks landing in that teardown window - // rather than firing a second delete against a dying realm. Reload is - // asynchronous, so those clicks are genuinely reachable. - if (!outcome || outcome.terminal !== true) resetPromise = null; - return outcome; - } catch (error) { - resetPromise = null; - throw error; - } + + try { return await resetPromise; } + finally { resetPromise = null; } } - async function request(options) { - var opts = options || {}; - // Checked before the confirm dialog: asking the user to authorise a - // destructive action we are about to refuse is worse than useless, and a - // second `deleteDatabase()` for a name that is already queued only grows - // the un-cancellable backlog. - var blockedByPending = pendingDeletionBlock(opts); - if (blockedByPending) { - if (blockedByPending.recoveryConfirmationRequired) { - var recoveryConfirmed = false; - try { - recoveryConfirmed = global.confirm( - '浏览器记录显示上一次数据库删除可能仍在等待。继续恢复会重新排队删除,' - + '请先关闭其他 IELTS Atlas 标签页;确定继续吗?' - ); - } catch (_) { recoveryConfirmed = false; } - if (recoveryConfirmed) { - opts = Object.assign({}, opts, { recoveryConfirmed: true }); - } else { - notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); - return blockedByPending; - } - } else { - notify(pendingDeletionMessage(blockedByPending.pendingDatabases), 'warning'); - return blockedByPending; - } - } - var confirmed = opts.confirmed === true; + async function request(options = {}) { + let confirmed = options.confirmed === true; if (!confirmed) { try { confirmed = global.confirm( @@ -677,92 +127,21 @@ + '练习记录、题库、词汇、设置、应用内备份和本地文件夹绑定都会清除;' + '外部文件夹中的 JSON 备份不会删除。' ); - } catch (_) { - confirmed = false; - } + } catch (_) { confirmed = false; } } - if (!confirmed) return { - success: false, - reason: 'cancelled', - deletionState: currentDeletionState() - }; + if (!confirmed) return { success: false, reason: 'cancelled', terminal: false }; - notify('正在清除全部本地数据…', 'info'); - try { - return await perform(opts); - } catch (error) { + notify('正在清除全部本地数据...', 'info'); + try { return await perform(options); } + catch (error) { if (global.console && typeof global.console.error === 'function') { global.console.error('[SiteDataReset] full reset failed:', error); } - notify('清除失败:' + (error && error.message ? error.message : '浏览器存储不可用'), 'error'); - return { - success: false, - reason: 'reset_failed', - deletionState: currentDeletionState(), - error: error - }; - } - } - - /** - * Adopt a marker written before the last reload and warn once. - * - * It never issues a delete. It does require explicit confirmation before a - * recovery reset, so stale evidence remains recoverable without being treated - * as proof that the late-deletion hazard disappeared. - * - * The warning is deferred because this module ships in core-foundation, - * which index.html loads *before* the ui-shell/legacy bundles that define - * `showMessage`. Warning synchronously would route the one notice the user - * actually needs into console.log instead of the message center. - */ - function adoptPendingDeletionsFromPreviousPage() { - adoptedPendingMarkerState = readPendingDeletionMarker(); - adoptedPendingDatabases = adoptedPendingMarkerState.databases; - if (!adoptedPendingDatabases.length) return; - var announced = false; - function announce() { - if (announced) return; - announced = true; - // Re-read: a reset may have completed and retired the marker while we - // were waiting for the UI layer to come up. - if (!adoptedPendingDatabases.length) return; - notify(pendingDeletionMessage(adoptedPendingDatabases), 'warning'); - } - if (typeof global.showMessage === 'function') { - announce(); - return; - } - var attempts = 0; - function poll() { - attempts += 1; - if (typeof global.showMessage === 'function' || attempts >= 20) { - announce(); - return; - } - hostSetTimeout(poll, 250); + notify(`清除失败:${error && error.message ? error.message : '浏览器存储不可用'}`, 'error'); + return { success: false, reason: 'reset_failed', terminal: false, error }; } - if (!hostSetTimeout(poll, 250)) announce(); } - global.SiteDataReset = Object.freeze({ - __v2: true, - DATABASE_NAMES: DATABASE_NAMES, - PENDING_DELETION_MARKER_KEY: PENDING_DELETION_MARKER_KEY, - deleteDatabaseStrict: deleteDatabaseStrict, - perform: perform, - request: request, - /** Names whose un-cancellable deletion has not reported back yet. */ - pendingDeletions: listPendingDeletions, - /** True while a previous deletion is still armed; see `pendingDeletions`. */ - isDeletionPending: function () { - return listPendingDeletions().length > 0; - }, - recoveryConfirmationRequired: function () { - return adoptedPendingDatabases.length > 0; - }, - deletionState: currentDeletionState - }); + global.SiteDataReset = Object.freeze({ __v2: true, DATABASE_NAMES, perform, request }); global.clearCache = request; - adoptPendingDeletionsFromPreviousPage(); })(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/data/v2/appData.js b/js/data/v2/appData.js index 37c06fff..8523c645 100644 --- a/js/data/v2/appData.js +++ b/js/data/v2/appData.js @@ -38,6 +38,7 @@ } return ''; } + function importedLibraryId(value, options = {}) { const id = value === null || value === undefined ? '' : String(value).trim(); if (!id && options.nullable) return null; @@ -106,143 +107,92 @@ }; } - function nonNegativeScalar(value) { - if (value === undefined || value === null || value === '' || typeof value === 'boolean') return null; - if (typeof value !== 'number' && typeof value !== 'string') return null; - const numeric = Number(value); - return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; - } - - function firstNonNegativeScalar(...values) { + function firstNonNegative(...values) { for (const value of values) { - const numeric = nonNegativeScalar(value); - if (numeric !== null) return numeric; + if (value === null || value === undefined || value === '' || typeof value === 'object') continue; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; } return null; } - function normalizeAnswerQuestionId(value, index = 0) { - const text = value === undefined || value === null ? '' : String(value).trim(); - return text || `q${index + 1}`; - } - - function normalizeAnswerValue(value) { - if (value === undefined || value === null) return ''; - if (Array.isArray(value)) return value.map((item) => normalizeAnswerValue(item)); - if (value && typeof value === 'object') { - if (hasOwn(value, 'answer')) return normalizeAnswerValue(value.answer); - if (hasOwn(value, 'userAnswer')) return normalizeAnswerValue(value.userAnswer); - if (hasOwn(value, 'value')) return normalizeAnswerValue(value.value); - return jsonValue(value, 'practice answer'); - } - return typeof value === 'string' ? value : String(value); - } - - function normalizeAnswerMap(value) { - const normalized = {}; - if (Array.isArray(value)) { - value.forEach((entry, index) => { - if (entry === undefined || entry === null) return; - const item = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}; - const questionId = normalizeAnswerQuestionId(item.questionId ?? item.id ?? item.name, index); - normalized[questionId] = normalizeAnswerValue( - hasOwn(item, 'answer') ? item.answer - : hasOwn(item, 'userAnswer') ? item.userAnswer - : hasOwn(item, 'value') ? item.value - : entry - ); - }); - return normalized; - } - if (!value || typeof value !== 'object') return normalized; - Object.entries(value).forEach(([questionId, answer], index) => { - if (questionId === '__proto__' || questionId === 'prototype' || questionId === 'constructor') return; - normalized[normalizeAnswerQuestionId(questionId, index)] = normalizeAnswerValue(answer); - }); - return normalized; - } - - function mergeAnswerMaps(...sources) { - const merged = {}; - for (const source of sources) { - const normalized = normalizeAnswerMap(source); - for (const [questionId, answer] of Object.entries(normalized)) { - if (!hasOwn(merged, questionId)) merged[questionId] = answer; - } - } - return merged; - } - - function canonicalizeAnswerSource(record) { - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const rawRealData = asObject(rawData.realData); - record.answers = mergeAnswerMaps( - record.answers, - record.answerMap, - record.answerList, - realData.answers, - realData.answerMap, - rawData.answers, - rawData.answerMap, - rawRealData.answers - ); - // These names remain accepted only at the compatibility boundary. The - // canonical detail layer owns one user-answer source: `answers`. - delete record.answerMap; - delete record.answerList; - } - - function normalizePracticeScoreFields(record) { + function normalizePracticeScore(record) { const scoreInfo = asObject(record.scoreInfo); - const realScoreInfo = asObject(asObject(record.realData).scoreInfo); - const rawScoreInfo = asObject(asObject(record.rawData).scoreInfo); - const overloadedCorrectAnswers = record.correctAnswers; - const isAnswerMap = overloadedCorrectAnswers && typeof overloadedCorrectAnswers === 'object'; - if (isAnswerMap) { - const existingMap = record.correctAnswerMap; - const overloadedObject = asObject(overloadedCorrectAnswers); - const existingObject = asObject(existingMap); - if (Object.keys(overloadedObject).length) { - record.correctAnswerMap = Object.assign({}, clone(overloadedObject), clone(existingObject)); - } else if (!existingMap || (Array.isArray(existingMap) && !existingMap.length)) { - record.correctAnswerMap = clone(overloadedCorrectAnswers); - } + const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo); + const overloadedAnswers = record.correctAnswers; + if (overloadedAnswers && typeof overloadedAnswers === 'object') { + record.correctAnswerMap = Object.assign( + {}, + clone(asObject(overloadedAnswers)), + clone(asObject(record.correctAnswerMap)) + ); } - - let correctAnswers = firstNonNegativeScalar( - overloadedCorrectAnswers, + const correct = firstNonNegative( + overloadedAnswers, record.correctAnswersCount, - record.correctCount, scoreInfo.correctAnswers, scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct, - rawScoreInfo.correctAnswers, - rawScoreInfo.correct + legacyScoreInfo.correctAnswers, + legacyScoreInfo.correct ); - if (correctAnswers === null) { - const comparisons = asArray(record.answerComparison).length - ? asArray(record.answerComparison) - : asArray(asObject(record.realData).answerComparison); - if (comparisons.length) { - correctAnswers = comparisons.filter((item) => item && item.isCorrect === true).length; - } - } - if (correctAnswers !== null) record.correctAnswers = correctAnswers; - else if (isAnswerMap) record.correctAnswers = 0; - - const totalQuestions = firstNonNegativeScalar( + if (correct !== null) record.correctAnswers = correct; + else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0; + const total = firstNonNegative( record.totalQuestions, record.questionCount, scoreInfo.totalQuestions, scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total, - rawScoreInfo.totalQuestions, - rawScoreInfo.total + legacyScoreInfo.totalQuestions, + legacyScoreInfo.total ); - if (totalQuestions !== null) record.totalQuestions = totalQuestions; + if (total !== null) record.totalQuestions = total; + } + + function mergeAnswers(target, source) { + if (Array.isArray(source)) { + source.forEach((item, index) => { + if (!item || typeof item !== 'object') return; + const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1); + const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer; + if (answer !== undefined) target[questionId] = clone(answer); + }); + return; + } + for (const [questionId, answer] of Object.entries(asObject(source))) { + target[String(questionId)] = clone(answer); + } + } + + function normalizePracticeAnswers(record) { + const answers = {}; + const raw = asObject(record.rawData); + const rawReal = asObject(raw.realData); + const real = asObject(record.realData); + for (const source of [ + rawReal.answerMap, rawReal.answerList, rawReal.answers, + raw.answerMap, raw.answerList, raw.answers, + real.answerMap, real.answerList, real.answers, + record.answerMap, record.answerList, record.answers + ]) mergeAnswers(answers, source); + if (Object.keys(answers).length) record.answers = answers; + } + + function questionTypeErrorCounts(source) { + const counts = {}; + const add = (type, count = 1) => { + const key = String(type || '').trim(); + if (key && count > 0) counts[key] = (counts[key] || 0) + count; + }; + for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) { + const metrics = asObject(value); + const total = firstNonNegative(metrics.totalQuestions, metrics.total); + const correct = firstNonNegative(metrics.correctAnswers, metrics.correct); + if (total !== null && correct !== null) add(type, Math.max(0, total - correct)); + } + for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) { + if (detail && detail.isCorrect === false) add(detail.questionType || detail.type); + } + return counts; } function canonicalizeRecord(input) { @@ -256,8 +206,8 @@ record.metadata = asObject(record.metadata); if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId; if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId; - canonicalizeAnswerSource(record); - normalizePracticeScoreFields(record); + normalizePracticeAnswers(record); + normalizePracticeScore(record); for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) { if (record[field] === undefined || record[field] === null || record[field] === '') continue; const numeric = Number(record[field]); @@ -268,97 +218,13 @@ return jsonValue(record, 'canonical practice record'); } - function deriveQuestionTypeErrorCounts(source) { - const record = asObject(source); - const realData = asObject(record.realData); - const rawData = asObject(record.rawData); - const counts = {}; - const addCount = (type, value) => { - const key = String(type || 'other').trim() || 'other'; - const amount = Math.max(0, Number(value) || 0); - if (amount > 0) counts[key] = (counts[key] || 0) + amount; - }; - const performanceSources = [ - record.questionTypePerformance, - realData.questionTypePerformance, - rawData.questionTypePerformance - ]; - let hasPerformanceData = false; - for (const performanceMap of performanceSources) { - if (!performanceMap || typeof performanceMap !== 'object' || Array.isArray(performanceMap)) continue; - let sourceHasPerformanceData = false; - for (const [type, value] of Object.entries(performanceMap)) { - const performance = asObject(value); - const total = Number(performance.total ?? performance.totalQuestions); - const correct = Number(performance.correct ?? performance.correctAnswers); - if (!Number.isFinite(total) || !Number.isFinite(correct)) continue; - hasPerformanceData = true; - sourceHasPerformanceData = true; - addCount(type, total - correct); - } - if (sourceHasPerformanceData) break; - } - if (hasPerformanceData) return counts; - - const questionTypeMap = Object.assign( - {}, - asObject(rawData.questionTypeMap), - asObject(realData.questionTypeMap), - asObject(record.questionTypeMap) - ); - const normalizedTypeMap = {}; - for (const [questionId, type] of Object.entries(questionTypeMap)) { - normalizedTypeMap[String(questionId).trim().toLowerCase()] = type; - } - const detailSources = [ - record.answerDetails, - asObject(record.scoreInfo).details, - realData.answerDetails, - asObject(realData.scoreInfo).details, - rawData.answerDetails, - asObject(rawData.scoreInfo).details - ]; - const seenQuestions = new Set(); - for (const details of detailSources) { - if (!details || typeof details !== 'object' || Array.isArray(details)) continue; - for (const [questionId, value] of Object.entries(details)) { - const detail = asObject(value); - const normalizedId = String(questionId).trim().toLowerCase(); - if (!normalizedId || seenQuestions.has(normalizedId)) continue; - let isWrong = detail.isCorrect === false || detail.correct === false; - if (detail.isCorrect === true || detail.correct === true) isWrong = false; - else if (!isWrong) { - const userAnswer = String(detail.userAnswer ?? detail.answer ?? detail.value ?? '').trim().toLowerCase(); - const correctAnswer = String(detail.correctAnswer ?? detail.expectedAnswer ?? detail.expected ?? '').trim().toLowerCase(); - isWrong = Boolean(correctAnswer && userAnswer && userAnswer !== correctAnswer); - } - if (!isWrong) continue; - seenQuestions.add(normalizedId); - addCount(detail.questionType || detail.type || normalizedTypeMap[normalizedId] || 'other', 1); - } - } - return counts; - } - function lightSuiteEntry(source, fallbackType = null) { const entry = asObject(source); const scoreInfo = asObject(entry.scoreInfo); const realScoreInfo = asObject(asObject(entry.realData).scoreInfo); const metadata = asObject(entry.metadata); - const totalQuestions = firstNonNegativeScalar( - entry.totalQuestions, - scoreInfo.totalQuestions, - scoreInfo.total, - realScoreInfo.totalQuestions, - realScoreInfo.total - ) ?? 0; - const correctAnswers = firstNonNegativeScalar( - entry.correctAnswers, - scoreInfo.correctAnswers, - scoreInfo.correct, - realScoreInfo.correctAnswers, - realScoreInfo.correct - ) ?? 0; + const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0; + const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0; const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy; const accuracy = normalizeAccuracyRatio( explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0), @@ -370,14 +236,14 @@ sessionId: entry.sessionId || null, examId: entry.examId || metadata.examId || null, title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '', - type: entry.type || metadata.type || metadata.examType || fallbackType || null, + type: entry.type || metadata.type || fallbackType, date: entry.date || entry.completedAt || entry.timestamp || null, duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0, totalQuestions, correctAnswers, accuracy, percentage, - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(entry) + questionTypeErrorCounts: questionTypeErrorCounts(entry) }, 'suite entry light projection'); } @@ -414,6 +280,7 @@ accuracy, percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0, score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null, + questionTypeErrorCounts: questionTypeErrorCounts(source), // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined` // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。 // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。 @@ -427,8 +294,10 @@ 'dataSource', 'source', 'libraryConfigurationId' ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])), suite: source.suite == null ? null : clone(asObject(source.suite)), - suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(entry, practiceType(source))), - questionTypeErrorCounts: deriveQuestionTypeErrorCounts(source) + suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry( + entry, + String(source.type || '').replace(/-suite$/, '') || null + )) }, 'practice light projection'); } @@ -449,7 +318,7 @@ return first === undefined ? {} : clone(first); } - const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries', 'questionTypeErrorCounts']); + const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']); const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']); function withoutRawData(value) { @@ -468,11 +337,10 @@ const detail = { recordId: source.id }; const annotations = { recordId: source.id }; for (const [key, value] of Object.entries(source)) { - if (key === 'realData' || key === 'rawData' || SUMMARY_FIELDS.has(key)) continue; + if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue; if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value); else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => { const next = Object.assign({}, asObject(entry)); - canonicalizeAnswerSource(next); const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData)); for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]); @@ -494,7 +362,7 @@ } // Accept the old mirror only as an input normalization boundary; it is never persisted. const realData = asObject(source.realData); const rawData = asObject(source.rawData); - for (const key of ['correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { + for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) { if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]); } for (const key of ANNOTATION_FIELDS) { @@ -707,8 +575,6 @@ // Entity records are authoritative. Projections are assembled on reads, never cached or // scheduled as follow-up work; this keeps a successful write immediately observable. - async function mutateAndProject(changes, options) { return kernel.mutate(changes, options); } - async function retryMergeConflict(options, task, maxAttempts = 3) { const explicitRevision = hasOwn(options, 'expectedRevision'); let lastError; @@ -808,51 +674,10 @@ function practiceLayerId(row) { return String(row && (row.recordId || row.id || row.sessionId) || ''); } - async function practiceProjectionSnapshot(recordIds = null, withMeta = false, stores = null) { - // The real kernel reads all three entity stores in one readonly - // IndexedDB transaction. Keep the fallback for deliberately minimal - // embedders and unit-test kernels that only expose the original methods. - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(recordIds, { withMeta, stores: stores || undefined }); - } - const ids = recordIds === null || recordIds === undefined - ? null - : (Array.isArray(recordIds) ? recordIds : [recordIds]) - .map((value) => String(value || '')) - .filter(Boolean); - const summaries = ids === null - ? await kernel.listEntities('practiceSummaries', { withMeta }) - : (await Promise.all(ids.map((id) => kernel.readEntity('practiceSummaries', id, { withMeta })))).filter(Boolean); - const targetIds = summaries.map(practiceLayerId).filter(Boolean); - const requestedStores = stores && stores.length ? stores : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; - const readLayer = (store) => Promise.all(targetIds.map((id) => kernel.readEntity(store, id, { withMeta }))) - .then((rows) => rows.filter(Boolean)); - return { - practiceSummaries: requestedStores.includes('practiceSummaries') ? summaries : [], - practiceDetails: requestedStores.includes('practiceDetails') ? await readLayer('practiceDetails') : [], - practiceAnnotations: requestedStores.includes('practiceAnnotations') ? await readLayer('practiceAnnotations') : [] - }; - } async function practiceLayers(recordId, withMeta = false) { - const snapshot = await practiceProjectionSnapshot([recordId], withMeta); + const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta }); const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; - return { - summary: find('practiceSummaries'), - detail: find('practiceDetails'), - annotations: find('practiceAnnotations') - }; - } - async function suiteChildRecordIds(command, aggregateRecordId) { - const ids = new Set(asArray(command.childRecordIds).map(String).filter(Boolean)); - const sessionIds = new Set(asArray(command.childSessionIds).map(String).filter(Boolean)); - if (sessionIds.size) { - const summaries = await kernel.listEntities('practiceSummaries'); - for (const summary of summaries) { - if (sessionIds.has(String(summary && summary.sessionId || ''))) ids.add(String(summary.id)); - } - } - ids.delete(String(aggregateRecordId)); - return ids; + return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') }; } function entityRevision(row) { return row ? Number(row.revision) : 0; } function practiceUpserts(recordId, layers, existing = {}) { @@ -864,76 +689,30 @@ } async function joinedPractice(recordId, projection, snapshot = null) { const mode = String(projection || 'full').toLowerCase(); - const layers = snapshot || await practiceProjectionSnapshot([recordId], false, - mode === 'light' || mode === 'summary' - ? ['practiceSummaries'] - : (mode === 'detail' || mode === 'medium' - ? ['practiceSummaries', 'practiceDetails'] - : null)); - const summary = asArray(layers.practiceSummaries) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const stores = mode === 'light' || mode === 'summary' + ? ['practiceSummaries'] + : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined); + const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores }); + const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null; + const summary = find('practiceSummaries'); if (!summary) return null; if (mode === 'light' || mode === 'summary') return clone(summary); - const detail = asArray(layers.practiceDetails) - .find((row) => practiceLayerId(row) === String(recordId)) || null; + const detail = find('practiceDetails'); if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode); - const annotations = asArray(layers.practiceAnnotations) - .find((row) => practiceLayerId(row) === String(recordId)) || null; - return joinPracticeRecord(summary, detail, annotations, mode); - } - function practiceSummaryTime(summary) { - const time = new Date(summary && (summary.completedAt || summary.date || summary.timestamp || summary.endTime || summary.startTime)).getTime(); - return Number.isFinite(time) ? time : 0; - } - function isReadingInsightSummary(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => practiceType(entry) === 'reading'); - } - return practiceType(asObject(summary)) === 'reading'; - } - function needsQuestionTypeInsightBackfill(summary) { - const suiteEntries = asArray(summary && summary.suiteEntrySummaries); - if (suiteEntries.length) { - return suiteEntries.some((entry) => - practiceType(entry) === 'reading' - && !hasOwn(asObject(entry), 'questionTypeErrorCounts')); - } - return !hasOwn(asObject(summary), 'questionTypeErrorCounts'); + return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode); } const practice = Object.freeze({ async list(options = {}) { await ready; const projection = String(options.projection || 'full').toLowerCase(); - const snapshot = await practiceProjectionSnapshot(null, false, - projection === 'light' || projection === 'summary' - ? ['practiceSummaries'] - : null); - const summaries = asArray(snapshot.practiceSummaries); + const summaries = await kernel.listEntities('practiceSummaries'); if (projection === 'light' || projection === 'summary') return summaries; - return (await Promise.all(summaries - .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))) - .filter(Boolean); - }, - async listInsights(options = {}) { - await ready; - const requestedLimit = Number(options.limit); - const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 - ? Math.min(requestedLimit, 50) - : 10; - const snapshot = await practiceProjectionSnapshot(null, false, ['practiceSummaries', 'practiceDetails']); - const summaries = asArray(snapshot.practiceSummaries) - .filter(isReadingInsightSummary) - .sort((left, right) => practiceSummaryTime(right) - practiceSummaryTime(left)) - .slice(0, limit); - return summaries.map((summary) => { - if (!needsQuestionTypeInsightBackfill(summary)) return clone(summary); - const detail = asArray(snapshot.practiceDetails) - .find((row) => practiceLayerId(row) === practiceLayerId(summary)) || null; - return detail - ? projectLight(joinPracticeRecord(summary, detail, null, 'detail')) - : clone(summary); - }); + const stores = projection === 'detail' || projection === 'medium' + ? ['practiceSummaries', 'practiceDetails'] + : undefined; + const snapshot = await kernel.readPracticeSnapshot(null, { stores }); + return (await Promise.all(asArray(snapshot.practiceSummaries) + .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean); }, async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); }, async completeAttempt(command) { @@ -953,9 +732,13 @@ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true }); if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId); const layers = splitPracticeRecord(input); const recordId = layers.summary.id; + const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String); + const children = new Set((await kernel.listEntities('practiceSummaries')) + .filter((summary) => practiceRecordMatches(summary, childIdentities)) + .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId']))); + children.delete(recordId); const receipt = await retryMergeConflict(command, async () => { const existing = await practiceLayers(recordId, true); - const children = await suiteChildRecordIds(command, recordId); const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id }))); return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation); }); @@ -998,6 +781,22 @@ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length }); }, async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); }, + async listInsights(options = {}) { + await ready; + const limit = Math.max(1, Math.min(50, Number(options.limit) || 10)); + const summaries = (await kernel.listEntities('practiceSummaries')) + .slice() + .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '') + .localeCompare(String(left.date || left.completedAt || left.timestamp || ''))) + .slice(0, limit); + return Promise.all(summaries.map(async (summary) => { + if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary); + const detail = await kernel.readEntity('practiceDetails', summary.id); + return jsonValue(Object.assign({}, clone(summary), { + questionTypeErrorCounts: questionTypeErrorCounts(detail) + }), 'practice insight'); + })); + }, async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); }, projectLight, projectDetail @@ -1245,88 +1044,6 @@ 'library.activeConfigurationId' ]); - function parseLegacyImportValue(value) { - if (typeof internals.parseLegacyValue !== 'function') { - throw new AppDataError('INITIALIZATION_BLOCKED', 'AppData v2 requires the canonical legacy value parser'); - } - return internals.parseLegacyValue(value); - } - - function decodePoisonedDocument(logicalKey, wrapped) { - const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey]; - if (!aliases || !isPlainImportObject(wrapped) - || !Object.prototype.hasOwnProperty.call(wrapped, 'key') - || !Object.prototype.hasOwnProperty.call(wrapped, 'value') - || !aliases.includes(String(wrapped.key))) { - return { matched: false, value: null }; - } - const decoded = parseLegacyImportValue(wrapped.value); - if (!isPlainImportObject(decoded)) return { matched: true, value: null }; - const overlay = {}; - for (const [key, value] of Object.entries(wrapped)) { - if (key === 'key' || key === 'value' || key === 'timestamp') continue; - overlay[key] = clone(value); - } - return { - matched: true, - value: Object.assign({}, decoded, overlay) - }; - } - - function repairPoisonedImportEnvelope(logicalKey, envelope, repairedKeys, warnings) { - if (!envelope || envelope.state !== 'present') return envelope; - const wrapped = envelope.data; - const decoded = decodePoisonedDocument(logicalKey, wrapped); - if (!decoded.matched || !decoded.value) return envelope; - const entry = catalog.get(logicalKey); - const next = internals.makeEnvelope(entry, decoded.value, { - revision: Number(envelope.revision) || 1, - operationId: String(envelope.operationId || randomId('import-repair')), - updatedAt: envelope.updatedAt - }); - repairedKeys.push(logicalKey); - warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); - return next; - } - - function validateLibraryImportBundle(envelopes) { - const presentKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); - if (!presentKeys.length) return { valid: true, presentKeys }; - if (presentKeys.length !== LIBRARY_IMPORT_KEYS.length) { - return { valid: false, presentKeys, reason: 'library snapshot is missing configurations, indexes, or active id' }; - } - const values = {}; - for (const key of LIBRARY_IMPORT_KEYS) { - const envelope = envelopes[key]; - values[key] = envelope.state === 'cleared' ? catalog.get(key).defaultValue() : envelope.data; - } - const configurations = asArray(values['library.configurations']); - const indexes = asObject(values['library.importedIndexes']); - const configurationIds = new Set(); - for (const configuration of configurations) { - const source = asObject(configuration); - const id = idOf(source, ['id', 'key', 'configId']); - if (!id || !acceptedLibraryId(id) || source.builtIn === true - || !Array.isArray(indexes[id]) || !indexes[id].length) { - return { valid: false, presentKeys, reason: 'library configuration does not have a matching non-empty custom index' }; - } - configurationIds.add(id); - } - for (const [id, index] of Object.entries(indexes)) { - if (!acceptedLibraryId(id) || !configurationIds.has(id) || !Array.isArray(index) || !index.length) { - return { valid: false, presentKeys, reason: 'library index is orphaned or invalid' }; - } - } - const activeId = values['library.activeConfigurationId']; - if (activeId !== null && (!acceptedLibraryId(activeId) - || !configurationIds.has(String(activeId)) - || !Array.isArray(indexes[String(activeId)]) - || !indexes[String(activeId)].length)) { - return { valid: false, presentKeys, reason: 'active library id is dangling or invalid' }; - } - return { valid: true, presentKeys }; - } - function canonicalizeV2Import(parsed) { const warnings = []; const repairedKeys = []; @@ -1334,41 +1051,43 @@ const envelopes = {}; for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) { if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`); - if (logicalKey === 'library.activeConfigurationId' - && rawEnvelope && rawEnvelope.state === 'present' - && String(rawEnvelope.data) === '[object Object]') { + const envelope = clone(rawEnvelope); + const data = envelope && envelope.state === 'present' ? envelope.data : null; + if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') { ignoredKeys.push(logicalKey); warnings.push('Skipped poisoned active library id'); continue; } - const repairCount = repairedKeys.length; - const repaired = repairPoisonedImportEnvelope( - logicalKey, - clone(rawEnvelope), - repairedKeys, - warnings - ); - const rawData = rawEnvelope && rawEnvelope.state === 'present' ? rawEnvelope.data : null; - const isLegacyRowWrapper = isPlainImportObject(rawData) - && Object.prototype.hasOwnProperty.call(rawData, 'key') - && Object.prototype.hasOwnProperty.call(rawData, 'value') - && String(rawData.key || '').startsWith('exam_system_'); - if (isLegacyRowWrapper && repairedKeys.length === repairCount) { - ignoredKeys.push(logicalKey); - warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); - continue; + if (isPlainImportObject(data) + && Object.prototype.hasOwnProperty.call(data, 'key') + && Object.prototype.hasOwnProperty.call(data, 'value') + && String(data.key || '').startsWith('exam_system_')) { + const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || []; + const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null; + if (!isPlainImportObject(decoded)) { + ignoredKeys.push(logicalKey); + warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`); + continue; + } + const overlay = Object.fromEntries(Object.entries(data) + .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp')); + envelope.data = Object.assign({}, decoded, overlay); + envelope.checksum = checksum(envelope.data); + repairedKeys.push(logicalKey); + warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`); } - envelopes[logicalKey] = repaired; - } - const library = parsed.scope === 'full' - ? validateLibraryImportBundle(envelopes) - : { valid: true, presentKeys: [] }; - if (!library.valid) { - for (const logicalKey of library.presentKeys) { - delete envelopes[logicalKey]; - ignoredKeys.push(logicalKey); + envelopes[logicalKey] = envelope; + } + + if (parsed.scope === 'full') { + const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key)); + if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) { + for (const key of presentLibraryKeys) { + delete envelopes[key]; + ignoredKeys.push(key); + } + warnings.push('Skipped incomplete library data'); } - warnings.push(`Skipped unsafe library data: ${library.reason}`); } const exportableKeys = catalog.list() .filter((entry) => entry.export === true && isImportableEntry(entry)) @@ -1376,9 +1095,7 @@ const missingKeys = parsed.scope === 'full' ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key)) : []; - if (parsed.scope === 'full' && missingKeys.length) { - warnings.push(`Full snapshot is sparse; missing keys will be preserved: ${missingKeys.join(', ')}`); - } + const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length); return { envelopes, warnings, @@ -1386,10 +1103,8 @@ ignoredKeys, missingKeys, declaredScope: parsed.scope, - effectiveScope: parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length) ? 'partial' : parsed.scope, - trust: parsed.scope === 'full' && !missingKeys.length && !ignoredKeys.length - ? 'trusted-full' - : 'degraded-partial' + effectiveScope: degraded ? 'partial' : parsed.scope, + trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial') }; } @@ -1650,9 +1365,6 @@ } async function currentEntitySnapshot() { - if (typeof kernel.readPracticeSnapshot === 'function') { - return kernel.readPracticeSnapshot(null, { withMeta: true }); - } const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true }); const result = {}; for (const store of PRACTICE_ENTITY_STORES) { @@ -1689,19 +1401,31 @@ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`); continue; } - const currentRead = await kernel.read(logicalKey, { withMeta: true }); - const currentData = currentRead && Object.prototype.hasOwnProperty.call(currentRead, 'data') ? currentRead.data : currentRead; - const currentEnvelope = currentRead && currentRead.envelope; - revisionToken.documents[logicalKey] = currentEnvelope ? Number(currentEnvelope.revision) || 0 : 0; + const current = await kernel.read(logicalKey, { withMeta: true }); + revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0; let next = envelope; if (!replaceDocuments && envelope.state === 'present') { - next = internals.makeEnvelope(entry, mergeImportValue(entry, currentData, envelope.data), { operationId: randomId('import-merge') }); + next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') }); } snapshot.envelopes[logicalKey] = next; keys.push(logicalKey); if (next.state === 'cleared') clearedKeys.push(logicalKey); } + // A full replace mirrors all exportable user data. Missing physical + // envelopes mean catalog defaults, represented here as explicit clears. + if (replaceDocuments && parsed.scope === 'full') { + for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) { + if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue; + snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, { + state: 'cleared', + operationId: randomId('import-clear') + }); + keys.push(entry.logicalKey); + clearedKeys.push(entry.logicalKey); + } + } + // Any successful practice import installs all three stores together. Merge // may update a subset only when the final recordId sets remain identical. const sourceStores = Object.keys(asObject(parsed.entities)); @@ -1914,7 +1638,7 @@ const mutation = optionsMutationOptions(options, 'vocab-words', words); return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.words', { withMeta: true }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.words', data: words, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -1956,7 +1680,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -1971,7 +1695,7 @@ return retryVocabMutation(options, async () => { const current = await kernel.read('vocab.lists', { withMeta: true }); const next = Object.assign({}, asObject(current.data), upserts); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -1998,7 +1722,7 @@ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord); list.updatedAt = nowIso(); collections[id] = list; - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -2016,7 +1740,7 @@ const current = await kernel.read('vocab.lists', { withMeta: true }); const collections = Object.assign({}, asObject(current.data)); collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() }); - return mutateAndProject([{ + return kernel.mutate([{ logicalKey: 'vocab.lists', data: collections, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -2082,7 +1806,7 @@ { id: listId, words: merged, updatedAt: nowIso() } ) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) @@ -2115,7 +1839,7 @@ : Object.assign({}, collections, { [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() }) }); - const receipt = await mutateAndProject([{ + const receipt = await kernel.mutate([{ logicalKey, data, expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0) @@ -2142,7 +1866,7 @@ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words }); changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 }); } - return mutateAndProject(changes, mutation); + return kernel.mutate(changes, mutation); }); } }); @@ -2277,253 +2001,95 @@ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'], 'achievements.manual': ['achievement_manual_state', 'user_achievements'] }); - const ONE_SHOT_LEGACY_DOCUMENTS = new Set([ - 'recovery.activeSessions', - 'recovery.drafts', - 'recovery.interrupted', - 'recovery.rejectedCompletions' - ]); const LEGACY_PREFERENCE_ALIASES = Object.freeze({ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse', practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode', ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed' }); - function legacyRecordArray(value) { - return Array.isArray(value) ? value : asArray(asObject(value).data); - } - function mergeLegacyExternalBackup(legacyValue, externalValue) { - const legacy = Object.assign({}, asObject(legacyValue)); + function mergeLegacySources(indexedDbValue, externalValue) { + const indexedDb = asObject(indexedDbValue); const external = asObject(externalValue); - const externalRecordKey = ['practice_records', 'practiceRecords'] - .find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (externalRecordKey) { - const records = new Map(); - for (const record of legacyRecordArray(external[externalRecordKey]).concat(legacyRecordArray(legacy.practice_records))) { - const recordId = idOf(record, ['id', 'recordId', 'sessionId']); - records.set(recordId ? `id:${recordId}` : `content:${checksum(record)}`, clone(record)); - } - legacy.practice_records = Array.from(records.values()); - } - for (const [target, aliases] of Object.entries({ - user_stats: ['user_stats', 'userStats'], - exam_index: ['exam_index', 'examIndex'], - storage_version: ['storage_version', 'storageVersion'] - })) { - if (Object.prototype.hasOwnProperty.call(legacy, target)) continue; - const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(external, key)); - if (alias) legacy[target] = clone(external[alias]); - } - return legacy; - } - - function acceptedLibraryId(value) { - const id = value === null || value === undefined ? '' : String(value).trim(); - if (!id || id === '[object Object]' || /^exam_index(?:_|$)/.test(id)) return null; - try { return importedLibraryId(id); } catch (_) { return null; } - } - - function remapLegacyLibraryId(value) { - return `legacy-library-${checksum(String(value)).replace(/^fnv1a-/, '')}`; + const merged = Object.assign({}, external, indexedDb); + const records = new Map(); + const addRecords = (value) => { + const list = Array.isArray(value) ? value : asArray(asObject(value).data); + list.forEach((record) => { + const id = idOf(record, ['id', 'recordId', 'sessionId']); + records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record)); + }); + }; + addRecords(external.practice_records || external.practiceRecords); + addRecords(indexedDb.practice_records); + if (records.size) merged.practice_records = Array.from(records.values()); + return merged; } - async function migrateLegacyLibraryData(legacy) { - const [configMeta, indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.configurations', { withMeta: true }), - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const legacyIdMap = new Map(); + function legacyLibraryBundle(legacy) { + const idMap = new Map(); const indexes = {}; - const addLegacyIndex = (oldId, value) => { - if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations') return; - const index = asArray(value); - if (!index.length) return; - const mappedId = remapLegacyLibraryId(oldId); - legacyIdMap.set(oldId, mappedId); - indexes[mappedId] = clone(index); - }; - - for (const [id, value] of Object.entries(asObject(legacy))) addLegacyIndex(id, value); - // Reconciliation is a union. A healthy current v2 value wins for the same - // deterministic library ID, while missing legacy libraries are restored. - for (const [id, value] of Object.entries(asObject(indexMeta.data))) { - if (/^exam_index_/.test(id)) addLegacyIndex(id, value); - else { - const acceptedId = acceptedLibraryId(id); - if (acceptedId && asArray(value).length) indexes[acceptedId] = clone(value); - } + for (const [oldId, value] of Object.entries(asObject(legacy))) { + if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue; + const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`; + idMap.set(oldId, id); + indexes[id] = clone(value); } - + if (!idMap.size) return null; const configurations = new Map(); - const addConfiguration = (configuration) => { - const source = asObject(configuration); - const oldId = idOf(source, ['id', 'key', 'configId']); - if (!oldId || oldId === 'exam_index') return; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - if (!id || !asArray(indexes[id]).length) return; - configurations.set(id, Object.assign({}, clone(source), { + asArray(legacy.exam_index_configurations).forEach((configuration) => { + const oldId = idOf(configuration, ['id', 'key', 'configId']); + const id = idMap.get(oldId); + if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length })); + }); + for (const [oldId, id] of idMap) { + if (!configurations.has(id)) configurations.set(id, { id, key: id, - examCount: indexes[id].length - })); - }; - asArray(legacy.exam_index_configurations).forEach(addConfiguration); - asArray(configMeta.data).forEach(addConfiguration); - for (const [oldId, id] of legacyIdMap) { - if (!configurations.has(id)) { - configurations.set(id, { - id, - key: id, - name: `迁移的自定义题库 (${oldId})`, - examCount: indexes[id].length, - sourceType: 'legacy-import' - }); - } - } - - const resolveActive = (value) => { - const oldId = value === null || value === undefined ? '' : String(value).trim(); - if (!oldId || oldId === 'exam_index' || oldId === '[object Object]') return null; - const id = legacyIdMap.get(oldId) || acceptedLibraryId(oldId); - return id && asArray(indexes[id]).length ? id : null; - }; - const currentRawActive = activeMeta.data; - let activeId = resolveActive(currentRawActive); - const currentIsExplicitDefault = Boolean(activeMeta.envelope) - && (currentRawActive === null || String(currentRawActive || '').trim() === ''); - if (!activeMeta.envelope || (!currentIsExplicitDefault && !activeId)) { - activeId = resolveActive(legacy.active_exam_index_key); - } - - const nextConfigurations = Array.from(configurations.values()); - const changes = []; - if (checksum(nextConfigurations) !== checksum(asArray(configMeta.data))) { - changes.push({ logicalKey: 'library.configurations', data: nextConfigurations, expectedRevision: configMeta.envelope ? Number(configMeta.envelope.revision) : 0 }); - } - if (checksum(indexes) !== checksum(asObject(indexMeta.data))) { - changes.push({ logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexMeta.envelope ? Number(indexMeta.envelope.revision) : 0 }); - } - if (activeId !== activeMeta.data) { - changes.push({ logicalKey: 'library.activeConfigurationId', data: activeId, expectedRevision: activeMeta.envelope ? Number(activeMeta.envelope.revision) : 0 }); - } - if (changes.length) { - await kernel.mutate(changes, { - operationId: `legacy-library-repair-v2-${checksum(changes)}` + name: `迁移的自定义题库 (${oldId})`, + examCount: indexes[id].length, + sourceType: 'legacy-import' }); } + return { + configurations: Array.from(configurations.values()), + indexes, + activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null + }; } async function migrateLegacyData() { // Unit embedders may provide a deliberately minimal kernel bootstrap. if (typeof internals.readLegacyValues !== 'function') return; - const legacySource = await internals.readLegacyValues(); - if (legacySource && legacySource.__legacyReadComplete === false) { - throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); - } const migrationMeta = await kernel.read('system.migrations', { withMeta: true }); const migrationState = asObject(migrationMeta.data); + const v1Complete = asObject(migrationState.v1ToV2).status === 'complete'; + const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed'; let externalBackup = null; - if (asObject(migrationState.externalBackupV1).status !== 'consumed' - && typeof internals.readLegacyExternalBackup === 'function') { - try { - externalBackup = await internals.readLegacyExternalBackup(); - } catch (error) { + if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') { + try { externalBackup = await internals.readLegacyExternalBackup(); } + catch (error) { if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message); } } - const legacy = externalBackup - ? mergeLegacyExternalBackup(legacySource, externalBackup) - : legacySource; - if (!legacy || !Object.keys(legacy).length) return; - - const documentMetas = {}; - for (const logicalKey of Object.keys(POISONED_V2_WRAPPER_ALIASES)) { - documentMetas[logicalKey] = await kernel.read(logicalKey, { withMeta: true }); - } - const [indexMeta, activeMeta] = await Promise.all([ - kernel.read('library.importedIndexes', { withMeta: true }), - kernel.read('library.activeConfigurationId', { withMeta: true }) - ]); - const documentRepairs = []; - const poisonedDocumentKeys = []; - for (const [logicalKey, current] of Object.entries(documentMetas)) { - if (!current.envelope || current.envelope.state !== 'present') continue; - const decoded = decodePoisonedDocument(logicalKey, current.data); - if (!decoded.matched) continue; - poisonedDocumentKeys.push(logicalKey); - const aliases = LEGACY_DOCUMENT_ALIASES[logicalKey] || []; - const legacyAlias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - const legacyValue = legacyAlias ? legacy[legacyAlias] : null; - let repairValue = decoded.value; - if (isPlainImportObject(legacyValue)) { - repairValue = Object.assign({}, asObject(decoded.value), clone(legacyValue)); - } - if (!repairValue) continue; - documentRepairs.push({ - logicalKey, - data: repairValue, - expectedRevision: Number(current.envelope.revision) - }); - } - const poisonedIndex = Object.values(asObject(indexMeta.data)).some((value) => - isPlainImportObject(value) - && Object.prototype.hasOwnProperty.call(value, 'key') - && Object.prototype.hasOwnProperty.call(value, 'value') - && /^exam_system_exam_index_/.test(String(value.key || ''))); - const poisonedActive = String(activeMeta.data) === '[object Object]'; - const libraryPoisoned = poisonedIndex || poisonedActive; - const poisonDetected = poisonedDocumentKeys.length > 0 || libraryPoisoned; - - await migrateLegacyLibraryData(legacy); - if (documentRepairs.length) { - await kernel.mutate(documentRepairs, { - operationId: `legacy-wrapper-repair-v1-${checksum(documentRepairs)}` - }); - } + if (v1Complete && !externalBackup) return; + const indexedDb = await internals.readLegacyValues(); + if (indexedDb && indexedDb.__legacyReadComplete === false) { + throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup'); + } + const legacy = mergeLegacySources(indexedDb, externalBackup); const changes = []; for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) { - const currentAudit = asObject(migrationState.v1ToV2); - if (ONE_SHOT_LEGACY_DOCUMENTS.has(logicalKey) && currentAudit.status === 'complete') { - continue; - } const current = await kernel.getEnvelope(logicalKey); + if (current) continue; const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key)); - if (!alias) continue; - const legacyValue = legacy[alias]; - if (!current) { - changes.push({ logicalKey, data: legacyValue, expectedRevision: 0 }); - continue; - } - const isBadMigrationWrite = current.state === 'present' - && /^legacy-documents-/.test(String(current.operationId || '')); - if (isBadMigrationWrite && checksum(current.data) !== checksum(legacyValue)) { - changes.push({ - logicalKey, - data: legacyValue, - expectedRevision: Number(current.revision) - }); - continue; - } - const entry = catalog.get(logicalKey); - if (current.state !== 'present' || !['patch', 'merge-by-id'].includes(entry.import)) continue; - let merged; - try { - merged = mergeImportValue(entry, legacyValue, current.data); - } catch (error) { - if (global.console && console.warn) { - console.warn(`[AppData v2] skipping malformed legacy document ${logicalKey}:`, error && error.message); - } - continue; - } - if (checksum(merged) !== checksum(current.data)) { - changes.push({ - logicalKey, - data: merged, - expectedRevision: Number(current.revision) - }); - } + if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 }); + } + const libraryBundle = legacyLibraryBundle(legacy); + if (libraryBundle) { + if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 }); + if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 }); } if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) { const preferences = {}; @@ -2538,84 +2104,52 @@ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) { changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 }); } - if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-reconcile-v4-${internals.checksum(changes)}` }); + if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` }); const recordsValue = legacy.practice_records; const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data); const operations = []; - let skippedRecords = 0; - const reconciledRecordIds = new Set(); - for (let index = 0; index < records.length; index += 1) { - const record = records[index]; - let canonical; - let parts; + for (const [index, record] of records.entries()) { try { - const candidate = jsonValue(record, 'legacy practice record'); - if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) { - candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const candidate = clone(record); + if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`; + const canonical = canonicalizeRecord(candidate); + const parts = splitPracticeRecord(canonical); + for (const [store, data] of [ + ['practiceSummaries', parts.summary], + ['practiceDetails', parts.detail], + ['practiceAnnotations', parts.annotations] + ]) { + if (!await kernel.readEntity(store, canonical.id)) { + operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 }); + } } - canonical = canonicalizeRecord(candidate); - parts = splitPracticeRecord(canonical); } catch (error) { - skippedRecords += 1; if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message); - continue; } - if (reconciledRecordIds.has(canonical.id)) continue; - reconciledRecordIds.add(canonical.id); - // Storage errors are not malformed records. Let them abort this repair so the - // completion marker is not written and the next startup can retry safely. - const existing = await practiceLayers(canonical.id, true); - if (existing.summary && existing.detail && existing.annotations) continue; - operations.push(...practiceUpserts(canonical.id, parts, existing)); } if (operations.length) { - await kernel.mutateEntities(operations, { operationId: `legacy-practice-reconcile-v4-${internals.checksum(operations)}` }); + await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` }); } - const migrationAudit = { - version: 4, + + const nextMigrationState = Object.assign({}, migrationState); + if (!v1Complete) nextMigrationState.v1ToV2 = { + version: 1, status: 'complete', - mode: 'persistent-reconcile', - sourceChecksum: checksum(legacy), - sourceRecordCount: records.length, - skippedRecordCount: skippedRecords + completedAt: nowIso(), + sourceChecksum: checksum(indexedDb), + sourceRecordCount: asArray(indexedDb.practice_records).length }; - const currentAudit = asObject(migrationState.v1ToV2); - const comparableCurrentAudit = { - version: currentAudit.version, - status: currentAudit.status, - mode: currentAudit.mode, - sourceChecksum: currentAudit.sourceChecksum, - sourceRecordCount: currentAudit.sourceRecordCount, - skippedRecordCount: currentAudit.skippedRecordCount - }; - const externalAudit = externalBackup ? { + if (externalBackup) nextMigrationState.externalBackupV1 = { version: 1, status: 'consumed', + completedAt: nowIso(), sourceChecksum: checksum(externalBackup) - } : null; - const currentExternalAudit = asObject(migrationState.externalBackupV1); - if (checksum(comparableCurrentAudit) !== checksum(migrationAudit) - || (externalAudit && (currentExternalAudit.status !== externalAudit.status - || currentExternalAudit.sourceChecksum !== externalAudit.sourceChecksum))) { - const nextMigrationState = Object.assign({}, migrationState, { - v1ToV2: Object.assign({}, migrationAudit, { - completedAt: nowIso(), - poisonDetected, - poisonedDocumentKeys, - libraryPoisoned - }) - }); - if (externalAudit) { - nextMigrationState.externalBackupV1 = Object.assign({}, externalAudit, { completedAt: nowIso() }); - } - await kernel.mutate([{ - logicalKey: 'system.migrations', - data: nextMigrationState, - expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 - }], { - operationId: `legacy-migration-reconcile-v4-${checksum(migrationAudit)}` - }); - } + }; + await kernel.mutate([{ + logicalKey: 'system.migrations', + data: nextMigrationState, + expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0 + }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` }); } const ready = kernel.initialize() diff --git a/js/main.js b/js/main.js index 1353e7d6..a0a95fdc 100644 --- a/js/main.js +++ b/js/main.js @@ -269,12 +269,6 @@ async function initializeLegacyComponents() { setupBrowsePreferenceUI(); - // Setup UI Listeners - const folderPicker = document.getElementById('folder-picker'); - if (folderPicker) { - folderPicker.addEventListener('change', handleFolderSelection); - } - // Initialize components if (window.PDFHandler) { pdfHandler = new PDFHandler(); @@ -2943,9 +2937,6 @@ async function setActiveLibraryConfiguration(key) { return await manager.setActiveLibraryConfiguration(key); } } -function triggerFolderPicker() { document.getElementById('folder-picker').click(); } -function handleFolderSelection(event) { /* legacy stub - replaced by modal-specific inputs */ } - // --- Library Loader Modal and Index Management --- // ... other utility and management functions can be moved here ... // --- Functions Restored from Backup --- diff --git a/js/runtime/unifiedReadingPage.js b/js/runtime/unifiedReadingPage.js index 1cdbd57d..62f5d39a 100644 --- a/js/runtime/unifiedReadingPage.js +++ b/js/runtime/unifiedReadingPage.js @@ -7289,22 +7289,6 @@ attachDragDrop(); attachPaneResizer(); - // Ensure drag items can return home when replaced or discarded - function initDragPools() { - document.querySelectorAll('.pool-items').forEach((pool, index) => { - if (!pool.id) { - pool.id = `practice-pool-${index}`; - } - }); - document.querySelectorAll('.pool-items .drag-item').forEach((item) => { - if (!item.dataset.originPool) { - const pool = item.closest('.pool-items'); - if (pool?.id) { - item.dataset.originPool = pool.id; - } - } - }); - } initDragPools(); attachUnifiedTimer(); From a25adb56b2f7deb3f497dbd8ded6bb4586a54ea1 Mon Sep 17 00:00:00 2001 From: Salloway Ma <94900220+githubSINGLE@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:51:41 +0800 Subject: [PATCH 18/18] ci: align Playwright browser version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8ecc4ab..cd9b8e7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: | npm ci --prefix developer python -m pip install --upgrade pip - python -m pip install playwright + python -m pip install playwright==1.56.0 python -m playwright install --with-deps chromium - name: Check JS bundle drift