Spell & Read is the practice book of ABC Books: McGuffey's Pictorial Eclectic Primer, Newly Illustrated, Newly Revised (1867), 87 lessons. It's the green book at the end of the shelf at https://games.503mail.com/abcbooks/. It's a different book from the 1881 Primer: it teaches the old way, "Let the child spell each word in the line, then read the line," starting with "Is it an ox? It is an ox." The app is rebuilt in the Primer post. Every block has a Copy button.
The book
- McGuffey's Pictorial Eclectic Primer, Wilson, Hinkle & Co., Cincinnati, 1867. There's no Gutenberg copy; the text comes from the archive.org scan (public domain).
- Lessons 1 to 12 are short lines made of two- and three-letter words. From Lesson 13 on, each lesson starts with a spelling list of about 12 words, then the reading lines. The last lessons are little stories: the hungry fox and the hen, the boy's kite, the swan, Fido the dog at dinner, and the two boys who leave the birds' nest alone.
- Like the books of its time, it splits long words into syllables ("lit-tle", "Ma-ry", "po-ny"); the app joins them back into whole words.
How the scan was cleaned up
The scan's text has many slips, so I corrected it by hand into spell1867.txt, lesson by lesson. Examples of what the scan said and what the book says:
- "Do we go U23?" = "Do we go up?", "Is it bv me?" = "Is it by me?", "Hotv fat the pig is!" = "How fat the pig is!", "Can the jiig run?" = "Can the pig run?", "It is niY cow." = "It is my cow."
- Spelling lists were printed in columns; the scan sometimes read them across, sometimes down. The order doesn't matter for spelling, so each list is just the words.
- Reconstructed spots are marked with a
?line in the file, and the app keeps them as the lesson's note:- Lessons 15, 27 and 35: the lesson heading and its word list are missing in the scan; the lines are complete. Lesson 27's number is a guess (the next heading in the scan is 29).
- Lesson 28: not in the scan at all, so the book goes from 27 to 29.
- Lesson 38: the first words are cut off; "The hen may say;" is filled in following Lessons 35 and 36 ("The cat may say", "The fox may say").
- Lesson 60: the first words are cut off; "The cat has a kit." is filled in from the word list.
- Lesson 63: only "well" of the word list is readable.
If you have the printed book, check those spots against it and fix the file.
How it plays
The lessons have the usual steps, plus a new one right after New words:
- Spell it π€: one line at a time, the words of the line as big buttons. The kid spells each word out loud, then reads the line. Tapping a word plays its spelling in Eloquence, with letter names: "I, S. is." π Line reads the whole line; Next line moves on.
- Lessons 1 to 12 have no printed spelling list, so their New words cards are the lesson's own words.
Add this book to ABC Books
You need the app from the Primer post and its C:\ABCBooks folder on the PC. Copy the files below the same way as in the Primer post (the scp and arrange-rebuild.ps1 steps); arrange-rebuild.ps1 puts spell1867.txt in C:\ABCBooks\source\.
1. Files
Proxmox host shell:
mkdir -p /root/abcbooks-rebuild
spell1867.txt: the whole book, hand-corrected. = n starts lesson n, words: is its spelling list, ? lines are notes about reconstructed spots.
cat > /root/abcbooks-rebuild/spell1867.txt <<'EOF'
# McGuffey's Pictorial Eclectic Primer, Newly Illustrated, Newly Revised (Wilson, Hinkle & Co., 1867).
# Hand-corrected from the archive.org scan text (mcguffeyspictori00mcgu_djvu.txt). Public domain.
# Format: "= n" starts lesson n; "words:" is the lesson's spelling list (when the book prints one);
# "?" lines are notes about a spot that had to be reconstructed; every other line is a line to spell and read.
# Syllable hyphens of the book (lit-tle, Ma-ry) are joined into whole words.
# Method of the book: "Let the child spell each word in the line, then read the line."
= 1
Is it an ox?
It is an ox.
It is my ox.
Do we go?
Do we go up?
We do go up.
Am I in?
Am I in it?
I am in it.
= 2
Is it an ax?
It is an ax.
It is my ax.
Is it by me?
My ax is by me. So it is.
Is he in?
He is in.
Is he by me?
Do we go in?
We do go in.
It is I.
It is he.
We do it.
Do as we do.
So we go on.
= 3
I see a pig.
How fat the pig is!
Can the pig run?
It can not run.
It is too fat.
Is it a cow?
It is a cow.
It is my cow.
Has she no hay?
Let her be fed.
= 4
See my fat ox.
Is it an old ox?
It is an old ox.
It is a dun ox.
It is not a red ox.
I see a nag.
Do you see it?
Yes, yes, I do.
The nag can run.
See it, O see it!
= 5
A fat pig.
Can it run?
A dun cow.
Has she hay?
= 6
See the rat.
It was hid.
Can the cat get the rat?
A red ox.
Let him go.
A sly hen.
Can she fly?
A bad dog.
It bit a pig.
= 7
O, see the fly!
How it can fly!
It bit an old ox.
The fly can run.
Run, fly, run.
A sly old ape.
It has a nut.
Get it for me.
May I get it?
Yes, if you can.
= 8
See my new top.
How it can hum.
You may get one.
Do not beg one.
I do not beg.
See! a new cap.
A cap for you.
I had a cap.
It was new.
Now it is old.
= 9
An old log hut.
A new log hut.
Is it for me?
Is it for you?
Why do you ask?
I see a tub.
The tub is big.
Can you use it?
O yes, I can.
I can use the tub.
= 10
Is it a bed?
It is a bed.
Is it for you?
The bed is for me.
Kit is on my bed.
O, see my cat!
He is on a mat.
He saw a rat.
The rat saw him.
The rat ran off.
= 11
It is an elk.
The elk is sly.
The dog saw him.
He saw the dog, and he ran off.
See the big kid.
It is a pet kid.
It is not shy, but it is gay.
Let us go out.
= 12
See the dog run.
It saw a man.
The man did say, pup, pup, pup.
And the dog ran.
A sly old fox.
A fat old hen.
The fox did try to get the hen.
The hen did fly.
= 13
words: so see far oh yes hop ah can Tom me you Ned
Ned, can you hop? Can you hop far?
Yes, I can hop. I can hop so far.
Can you hop, Tom? Can you hop to me?
Yes; see me. I can hop to you, Ned.
Ah! so you can. Now let us hop.
= 14
words: as six you am old why are big how ten but Ann
Ann, how old are you? I am six.
Are you but six? Why, I am ten.
But you are not as big as I am.
Yes, I am; I am as big as you.
O no, Ann; you are not so big.
= 15
? heading and word list missing in the scan; the lines are complete
Can the dog get the rat?
Yes, he can get the rat.
See, see, how sly he is!
Did you see the rat run?
The dog did not let it run far.
= 16
I see a dog. The dog bit my pig.
Is my pig in the sty? Let us see.
O yes; the pig is in the sty.
The dog can not see my pig now.
Let the fat old pig be fed.
= 17
A fan for Ann.
Can you fan me?
I can fan you.
You can fan me, and you do.
I see you, cat.
Do you see me?
The cat is on my new fur cap.
Get off, old cat.
= 18
Do you not see Ned and his nag?
Is it Ned? Is Ned on the nag?
Yes; it is Ned. He is on the nag.
Can the nag run? O yes, it can.
The nag can run; so can Ned.
= 19
I see a dog.
I can see a pup.
Do you see me?
The dog and pup may run all day.
I see an old cat.
The old cat is by her pet kit.
The cat and kit are on a rug.
= 20
words: hen fat fly she too far but run old not was see
O Ann, do you see the old hen?
Is she not fat? Can she fly?
She can fly, Ann, but not far.
The old hen is too fat to fly far.
But she can run. Run, old hen, run.
= 21
words: dog boy get log joy let hog toy set fog coy bet
Do you see the boy and his dog?
Is it a dog, or is it a fox?
Is it a fox? O no, it is a dog.
The dog can run; so can the boy.
= 22
words: bee the do see can him hum fly boy bud you joy
Do you see the bee? Is it on the bud?
O yes! I see the bee. It is on the bud.
Can the bee fly? Can it hum too?
The bee can fly and hum. Ah! so it can.
Now, old bee, let us see you fly.
= 23
words: Hal too odd box pet bad boy pig but Ned was who
Ned has a pet pig. Do you see it?
Is it not an odd pet? Can it run?
Hal has a pet hen. His hen can fly.
Ned, who has the pig, is a bad boy.
Hal is a big boy, but not a bad boy.
= 24
words: us hay new we our hat put out who day fun with
It is a hot day. Let us go out.
Let us go out with our dog.
We can go to the new-cut hay.
We can put hay on our dog for fun.
Let us go and play with the dog.
= 25
words: off cat sat box boy lid eye ran bid and got put
A boy put a cat in a box.
A hen was in the box.
The boy sat on the lid of the box.
The boy got off the lid of the box.
The cat got out and ran off.
= 26
words: in ill up as but get the hay hen sun too was
The hen was too ill to get up; but she was not so ill as to die.
The hen was put on the hay. She was put on the hay in the sun.
The poor old hen may die.
= 27
? heading and word list missing in the scan (the book's next page has no lesson number); numbered 27 here
Ann had a pet lap-dog. She let it lie on her bed.
She fed it of all she had to eat.
Her dog was not as big as a kid; not as big as a kid one day old.
Ann bid her dog put up its red paw.
The dog did as it was bid. It put its paw in her lap. Did you not see it?
It put up no paw but the red one.
Ann and her dog can run out and play.
= 29
? the scan has no Lesson 28 at all; this is the book's Lesson 29
words: see rose why red two them bud will like buds soon stem
Do you see the rose?
See, a red, red rose, and a bud.
A bud? Why, I see two buds.
The bud will soon be a rose.
A rose and two buds on one stem.
= 30
words: lie mat bark fat rug like pup pur puss dog nor bite
My fat pup can bark like a dog.
Pup will lie on a mat or a rug.
My puss will sit by him and pur.
But the pup will not bite puss.
Nor will puss bite my fat pup.
= 31
words: yet red will you hot soon sun now know but how made
Do you see the sun? Is it up?
No, it is not yet up; but it will be soon.
Ah, see, see! Now it is up. How red it is. It will soon be hot.
Do you know who made the sun?
= 32
words: Ned will him Ann bite them old good with dog play Nero
Ned and Ann and old Nero.
Nero is a good dog. He will not bite Ann or Ned.
He will let them play with him all day.
Is not our Nero a good dog?
= 33
words: air rice rat fair vice rats hair nice cats pair mice hats
A dog will bark and run and play.
A cow will give milk if well fed.
A hen will lay eggs on the hay.
A sly cat will get mice and rats.
A bird will sing in the tree all day.
= 34
words: up six get lie now why bed day Lucy gild you Mary
Get up, Lucy. Do not lie in bed now.
It is day, and the sun is up. Mary got up at six, and is out at play.
Up, up, Lucy, why do you lie in bed?
Get up, Lucy, and go out to Mary.
= 35
? heading and word list missing in the scan; the lines are complete
The cat may say; I do not sip, I lap.
I can run. See, I can get a rat.
I can get a fly, if it is not too far off.
I can mew, and I can lie in the sun.
I can lie on a rug, or on the hay.
= 36
The fox may say; I am sly.
I had an old fat hen. A man saw me.
And a dog saw me. I ran and hid.
I am so sly, a man can not get me.
A dog can get me, if I do not run.
= 37
words: oh air fit not aid all our for we God has eye
O my God, let me do no sin, but aid me to do as I am bid.
Let me do to all, as all are bid to do to me. Our God can see all we do.
Let all I do be fit for his eye.
= 38
? the first words of this lesson are cut off in the scan; "The hen may say;" is filled in from Lessons 35 and 36
words: far ten use lay hen man car pen egg play men eggs
The hen may say; I can run. I can fly, but not far up in the air.
I can lay eggs, and am of use to man.
The fox and the rat may get me; but if I see the fox or rat, I run off.
= 39
words: net swim boy fish bird pole hand lend line round rend
A fish, a net, a pole, and a line.
Can a fish swim? Can it swim far?
O yes, a fish can swim all day.
It can swim as far as a boy can run, or a little bird can fly.
= 40
words: Hal look kind fine must trot find black fast pony
Ho, look at Hal on his pony.
Is it not a fine, black pony?
See, how fast he can trot.
He can run too. He can run and trot with Hal on his back.
Hal must be kind to his fine pony.
= 41
words: come shy book look that brook deer wild sink does down drink
O Tom, come and look at the deer.
At the deer? Why, Ned, is that a deer?
Yes, it is a deer. Can you not see it?
Does not the deer look shy and wild?
He has come down to the brook to drink.
= 42
words: air fly off bee tea my dew bud sip why may cup
Do you see my cup? A bee is on it.
Why did the bee get on the cup?
My cup is not for a bee.
The bee got on the cup to sip the tea.
Sip and be off, bee. Fly out into the air.
Get into the bud, and sip its dew.
= 43
words: Jane took Sam poor left little floor head broken room broke token
Poor Jane! Do you see her doll?
It is broken. Sam Page did it.
Jane left her doll, and Sam took it.
He let it fall on the floor, and now it is broken. Its head is broken off.
Do you not see it on the floor?
= 44
words: the big has box you Ann see new cover was said Mary
Mary has a new box, a big box.
Let us go and see it. The box is red.
Mary said it was for her; so, Ann, it can not be for you.
It has an M on the cover; M for Mary.
= 45
words: shut good frock book bite must your bark long with tear Bruno
Ann, you may shut your book now.
You can go out and see the sun set.
Little Bruno may go with you.
Bruno is a good dog. He will not bite, or bark, or tear your frock.
You must not stay out too long.
= 46
words: red cap how get will soon out Ned down sun why setting
May I get my cap, Ned? We can go and see the sun set.
See, Ned, how red it is. Why is the setting sun so red?
It will soon be down. Now let us go.
= 47
words: age met cow lad two make boy add little man said driving
An old man met a boy and a cow.
The old man said to the boy, My lad, how old are you?
The boy said, Sir, if you add two to my age, it will make ten.
= 48
words: Sly pig eel man hen now met how feel must why heel
Sly will do as he is bid. He is a pet dog.
He will run at a pig or a cow.
He will run at a fox or an ox.
He will run at a hen or a rat.
A fox or a pig will not run at Sly.
= 49
words: said cart away trying make told today mister must rope sitting dinner good hand hungry summer
One summer day, a hungry fox saw a fat hen sitting on a box.
The sly fox said, I can get a dinner now; but not so.
A big boy saw mister Fox, as he was trying to get the hen.
The boy ran for his gun. The fox saw the boy go for the gun.
Ah! said mister Fox, I can not get a fat dinner today.
If I am not off, the boy may get me.
So away ran the fox.
= 50
words: bird hook long cage sits poor hang sing think hangs sings away
Do you see the little bird in its cage?
The bird sits in his cage and sings.
He will sing all the day long.
Poor bird! do you not think he would love to be free, and fly away in the air?
= 51
words: old sick son like care you poor once take blind must little
This old man is poor, and ill, and blind.
He is led by his dog, a little red dog.
Once he was a little boy, like you; but now he is old, and sick, and poor.
He has no son to take care of him.
He must be led by his little dog.
= 52
words: large call fine draw calls them here take both spade ride pony
Here are Ned and Hal at play.
Ned is a little boy, but Hal is a large boy.
Hal has a spade, and little Ned is on it.
Ned calls Hal his pony, and says he will take a ride. It is fine fun for them.
= 53
words: sat gun had say saw tree oak owl after said shot sunset
An owl sat in the top of an oak tree.
The owl can not see by day; but it can see after sunset.
A boy saw the owl, and said to a man, An owl is in the oak.
The man took his aim, and shot the owl.
= 54
words: went have face lost poor side shot look man knee looks man's
This is Tom Brown. Poor man!
He was shot in the knee, and had to have his leg cut off.
See his little dog. He looks up in the face of the man, and trots on by his side.
= 55
words: see rat will and ran has hay yes out cat saw how
I saw the cat out by the hay.
She ran as if she saw a rat.
If it is a rat, she will get it.
Let us go and see if she has one.
Yes, she has a rat, a big one.
= 56
words: pen cap saw out one only pig two little sty boy going
Do you see the boy? It is little Ned.
Has Ned a new cap? Can you see it?
He is at the sty to see his pig.
I saw it fed at one. It is now only two.
The pig can not get out of its pen.
= 57
words: mill fall turn side falls flour wheel make bread round makes water
Here is a mill. Do you see the wheel?
It is on the side of the mill. The water falls on the wheel and makes it turn.
In the mill, they grind grain and make flour of it for our bread.
= 58
words: try eel low out see you use saw may can mud now
I saw an eel in the mud. May I get it?
May I try now? No, it is of no use.
It is low in the mud. You can see it; but you can not get it if you try.
= 59
words: come book that here stand shut what large Mary lamp near open
Come, Mary, and tell me what you see.
I see a lamp and a book on the stand.
A book is near the lamp, a large book.
Do you see the book? Is it open?
One book is open; but near it, is a book that is shut.
= 60
? the first words are cut off in the scan; "The cat" is filled in from the word list
words: am bid her cat kit now ask but will may you why
The cat has a kit.
Do not go to her now, but sit by me.
Why may I not go to her now?
Do not ask why, but do as you are bid.
I will do as I am bid. I will not go.
= 61
words: kite wing side dive each would will should fall rise could
See the boy with his new kite.
Do you see it dive in the air?
O, it has but one wing! It will fall.
If it had a wing on each side, it would rise.
Fix your kite, my boy, and then it will fly.
= 62
words: will ant away bark you afraid puss dish eating pur from flying
As a pup was eating from a dish, it saw a bee and an ant.
The bee was not on a bud, but was flying in the air.
The ant did not fly. An ant can not fly, but it can run.
The pup put its paw on the ant, and ran away from the bee.
It was a big bee, and the pup was afraid of it.
A pup will try to bark like a dog.
A dog will lie on a mat, or a rug.
Puss will pur, if I put her on my lap.
Puss is not afraid of the pup; but she is afraid of the old dog.
= 63
? the word list is missing in the scan except "well"
words: well
Come, Ned, let us go out to the well.
Is the well deep? Yes, it is very deep.
Let us look down into the well.
We shall not fall, for a curb is round the well. Lean over the curb, and look.
= 64
words: cart tree egg bark bird fast hark sing give mark milk horse
The horse can draw the cart and man.
The horse can run and trot very fast.
An ox or cow can not run so fast as the horse can.
Nor can a dog run so fast or so far.
= 65
words: fine large very nice come into what home Mary peach shall Lucy
O Tom, come and see my peach.
Is it not a nice large one?
Is it for me, or is it for you?
It is not for you or me. It is for Lucy.
Ah, what a fine peach for Lucy.
= 66
A dog saw a rat.
A cat saw it too.
The dog ran for it, but the cat got it.
Why did not the dog get it?
Ah! the cat was hid; and as the rat got out of a box, she put her paw on it.
Will she not kill it? Yes, she will.
= 67
words: Ann will boys Tom call girls Tray you dogs tell with hens
Do you see our old Tray, the big dog?
He is fed by Ann and Tom. Tray will run if Ann or Tom calls him.
Now, Tray, let me see how you can run.
Run, Tray, run; run to Ann.
= 68
words: this look cart then black drink plow come grass draw stream strong
Look at this old black ox.
He has come to the stream to drink.
Drink, ox, and then go and eat grass.
The ox can draw the plow. He can draw the cart too, for he is strong.
= 69
words: lamb lost looks old coat one goat kid creep how sheep young
Is it a sheep or a goat, or is it a kid?
It is not a goat or a kid. It is a sheep.
Is it an old sheep, or is it a lamb?
It is an old sheep. She has lost her lamb. How sad she looks.
= 70
words: here with you hold gave new held when rose went then line
Tom said to little Ned, See my kite!
When it is in the air, you may hold it.
Ned went with Tom, and held the kite.
Tom ran, and the kite rose. Then Tom gave the line to Ned.
= 71
words: cow tail lion paw kill letter blow long better nest mane fetter
Is it a dog, or a cow, or an ox?
No; it is not a dog, or a cow, or an ox.
It is a lion. See his long mane and tail.
The lion can kill a man. He can kill a man with one blow of his paw.
= 72
words: bird tree nest four lost took plum want were eggs seems sorry
The bird has lost her nest.
See how sad and sorry she seems.
Sam Page saw the nest and took it.
He took it for the four eggs that were in it. Was he not a bad boy?
= 73
words: lips eyes Page doll shoes brown gave small little hair cheeks Susan
Little Jane Day has a new doll.
She went to see Susan Page, and Susan gave her this doll.
It is a wax doll, and has red lips.
It has brown hair, and red shoes.
Jane has a small box to put it in.
= 74
words: air duck fast fair luck past take pond swift make bond swim
Do you see the duck? Can it swim?
Can it fly too? Yes, the duck can fly.
It can swim in the pond, or fly in the air.
The duck can swim in the pond all day.
= 75
words: cap dead strong man with kill him poor killed paw near lion
Do you see the man? He is dead.
The lion killed him with his big paw.
The lion is very strong. He can kill an ox or a horse.
The cap and cane of the man are near him.
= 76
words: dark horse eyes your take must come small carry move tail stable
I like this horse. I like his long tail.
Come, sir, trot a little; I must see you move. So! you carry your tail well.
Your head is up. Take him to the stable, and let him be fed.
= 77
words: this goes glossy thing lights golden bird rises settles wing sister shining
O sister, see this shining thing.
Now it lights on the rose, and waves its glossy wings.
Is it a bird, or is it a bee?
Now it rises, and up it goes in the golden light.
= 78
words: now good who nice book aunt such kind your have come gave
Do come, Mary, and see my new book.
A book, Lucy, have you a new book?
O yes, and it is such a nice one too.
Ah, so you have. Who gave it to you?
Your Aunt? How kind and good she is.
= 79
words: since have catch steal mice closed dear sings moment harm wakes morning
Look at puss and our bird. If puss gets the bird, she will kill and eat it.
Puss may catch the mice, for they steal and do us harm.
But puss must not have our bird, that sings and wakes us in the morning.
= 80
Ah! do you see the old puss?
She is lying on the rug in the sun.
Her eyes are closed, but she is not sleeping. Is she not very sly?
A moment since her eyes were open.
She will get our bird if she can.
= 81
words: meet begun woods music duty moments labor morning
The lark is up to meet the sun,
The bee is on the wing;
The ant its labor has begun,
The woods with music ring.
Shall birds, and bees, and ants, be wise,
While I my moments waste?
O let me with the morning rise,
And to my duty haste.
= 82
Let us go and see the new cart.
Is it a new cart, or is it an old one?
It is new. Do you see the fork too?
O yes; a new cart and an old fork.
Let us sit on the cart and play.
= 83
words: sake able soiled your forget uncle much hoped study would kissed careful
My uncle came to see me today, and gave me this new book.
Do you see my book? I will try to study it well.
When my uncle gave it to me, he kissed me, and said that he hoped, when he came to see me next year, he would find me able to read well.
I shall try to read well, for his sake as well as my own; for I love my uncle very much.
I will try not to forget what I read in this book.
Uncle told me to be careful of my book. I shall do so; for I do not like to use books that are torn and soiled.
Do you?
= 84
words: neck eggs young large swan sticks make which goose short hatch months
This is a swan, which is a large bird.
It has a long neck and short legs.
It is as white as snow.
The food of the swan is the same as the food of the goose.
Swans make their nests of short sticks and grass.
Their eggs are large and white; and they hatch them in two months.
Did you ever see a young swan?
The young swan is not white, but is gray.
= 85
words: one stop with two noon black plow soon white rest come horse
Can the man plow with one horse?
O yes, very well; but he has two.
Ah, so he has; a black and a white one.
Can he plow all day? Yes; but he will stop at noon to rest.
= 86
words: two glad when felt floor killed that ago mother days about running
Mary is very happy. Her mother gave her a new bird today.
Two days ago, the bird that she then had, was running about the floor.
A cat killed it. The little girl felt very sad. Now she is happy.
= 87
words: nice word whose says chair fancy speak straw master name known Edward
Sister Mary, come here and see Fido.
He is sitting up, and has a hat on his head.
Do you not fancy that it is a little boy in the chair? It is only Fido.
Shall I ask him to stay and dine with us today?
Yes. Little master Fido, we are to have a very nice pig for dinner today; will you stay and take a rib with us?
If you like, you can have a bit of pie also. He says not a word.
Fido can not speak as we do, yet he has ways by which he is able to make his wants known.
Edward was the name of the boy. Fido was his dog.
= 88
words: saw two nest said cage young bush stolen were would boys prison have into girls garden went other there parents
One day, two boys went into a garden.
They saw a bird's nest on a rose-bush.
Two little birds were in the nest.
One of the boys said, Let us take these birds into the house.
We can put them into a cage. By and by they will sing for us.
No, said the other boy; birds have parents as well as boys and girls.
We will not take these young ones. The old birds will feel very sad.
Our parents would feel very sorry, if we were stolen and put in prison.
EOF
make-book.ps1: the converter; -Book spell1867 reads the file above instead of a Gutenberg text.
cat > /root/abcbooks-rebuild/make-book.ps1 <<'EOF'
# Builds public/books/<book>.json from a Project Gutenberg text of a McGuffey Reader.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-book.ps1 -Book primer1881
# powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-book.ps1 -Book first1879
param([ValidateSet('primer1881', 'first1879', 'second1879', 'third1879', 'fourth1879', 'fifth1879', 'sixth1879', 'spell1867')] [string]$Book = 'primer1881')
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$books = @{
# Spell & Read: the 1867 Pictorial Primer, hand-corrected from a scan into source/spell1867.txt (no Gutenberg text exists)
spell1867 = @{ hand = $true; file = 'spell1867.txt'; title = 'Spell & Read'; sub = 'Pictorial Primer, 1867'; color = '#5b6f3a'
source = 'McGuffey''s Pictorial Eclectic Primer, 1867 (archive.org mcguffeyspictori00mcgu, public domain), hand-corrected' }
primer1881 = @{ gutenberg = 14642; file = 'primer.txt'; title = "McGuffey's Eclectic Primer"; sub = 'Revised Edition, 1881'; color = '#b5562c'
end = 'SLATE EXERCISES'; pics = @{ 27 = 'ποΈ'; 46 = 'βοΈ' } }
first1879 = @{ gutenberg = 14640; file = 'first.txt'; title = "McGuffey's First Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#2c6fb5'
end = 'PHONIC CHART.'; pics = @{} }
second1879 = @{ gutenberg = 14668; file = 'second.txt'; title = "McGuffey's Second Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#3f8f4f'
end = '*** END OF THE PROJECT GUTENBERG'; pics = @{} }
# prose = later Readers: no word lists in Gutenberg's copy (new words are worked out), printed page headers to remove,
# numbered paragraphs that run on without blank lines, plays ("Rose. ...") and an author line under poems
third1879 = @{ gutenberg = 14766; file = 'third.txt'; title = "McGuffey's Third Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#7b4fb5'
end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|THIRD READER'
after = @('primer1881', 'first1879', 'second1879') }
# Fourth Reader: headings carry the title ("I. PERSEVERANCE. (25)"), and each lesson ends with DEFINITIONS (the book's
# own new words, with meanings), EXERCISES (questions to talk about) and sometimes NOTES
fourth1879 = @{ gutenberg = 14880; file = 'fourth.txt'; title = "McGuffey's Fourth Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#b5892c'
end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|FOURTH READER'
start = 'I. PERSEVERANCE. (25)'; tocStart = 'SELECTIONS IN PROSE AND POETRY.'; tocEnd = 'LIST OF ILLUSTRATIONS'
headRx = '(?m)^([IVXLCl]+)[.:][ \t]+([A-Z][^a-z\n]*?(?:\((?:Concluded|Continued)\.?\))?[^a-z\n]*?)[ \t]*(?:\(\d+\))?[ \t]*$'
after = @('primer1881', 'first1879', 'second1879', 'third1879') }
# Fifth Reader: like the Fourth, but headings have no page number ("II. THE BLUEBELL.") and the first one is scanned as "1."
fifth1879 = @{ gutenberg = 15040; file = 'fifth.txt'; title = "McGuffey's Fifth Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#2c9bb5'
end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|FIFTH READER'
start = '1. THE GOOD READER.'
headRx = '(?m)^(\d+|[IVXLCl]+)[.:][ \t]+(["\u201c]?[A-Z][^a-z\n]*?(?:\((?:Concluded|Continued)\.?\))?[^a-z\n]*?)[ \t]*(?:\(\d+\))?[ \t]*$'
tocEnd = 'LIST OF ILLUSTRATIONS'
after = @('primer1881', 'first1879', 'second1879', 'third1879', 'fourth1879') }
# Sixth Reader: headings with page numbers like the Fourth; the lessons start after the chapters on reading aloud
sixth1879 = @{ gutenberg = 16751; file = 'sixth.txt'; title = "McGuffey's Sixth Eclectic Reader"; sub = 'Revised Edition, 1879'; color = '#8f3f5f'
end = '*** END OF THE PROJECT GUTENBERG'; pics = @{}; prose = $true; pageRx = 'ECLECTIC SERIES|SIXTH READER'
start = 'I. ANECDOTE OF THE DUKE OF NEWCASTLE.'
headRx = '(?m)^(\d+|[IVXLCl]+)[.:][ \t]+(["\u201c]?[A-Z][^a-z\n]*?(?:\((?:Concluded|Continued)\.?\))?[^a-z\n]*?)[ \t]*(?:\(\d+\))?[ \t]*$'
tocEnd = 'LIST OF ILLUSTRATIONS'
after = @('primer1881', 'first1879', 'second1879', 'third1879', 'fourth1879', 'fifth1879') }
}
$cfg = $books[$Book]
$pics = [ordered]@{ 'rat'='π'; 'cat'='π'; 'kitten'='π±'; 'dog'='π'; 'frog'='πΈ'; 'chick'='π€'; 'hen'='π'; 'fox'='π¦'; 'duck'='π¦'; 'owl'='π¦'; 'bird'='π¦'; 'cow'='π';
'rabbit'='π'; 'pig'='π'; 'lamb'='π'; 'kite'='πͺ'; 'sled'='π·'; 'top'='π©'; 'ball'='β½'; 'squirrel'='πΏοΈ'; 'bee'='π'; 'boat'='β΅';
'ship'='β΅'; 'skat'='βΈοΈ'; 'horse'='π'; 'train'='π'; 'goat'='π'; 'burning'='π₯'; 'school'='π«'; 'quail'='π¦'; 'baby'='πΆ'; 'lighthouse'='πΌ';
'sheep'='π'; 'snow'='βοΈ'; 'fishing'='π£'; 'doll'='πͺ'; 'sawing'='πͺ'; 'lamp'='πͺ'; 'seashore'='ποΈ'; 'blind'='π¦―'; 'mill'='ποΈ';
'sunset'='π
'; 'woman'='π©'; 'wagon'='π'; 'acorn'='π°'; 'drum'='π₯'; 'lake'='π'; 'writing'='βοΈ'; 'man'='π΄'; 'girl'='π§'; 'boy'='π¦'; 'children'='π§' }
if ($cfg.hand) {
# Hand-corrected book (source/<file>): "= n" starts a lesson, "words:" is its spelling list, "?" lines are notes, "#" comments
$lessons = @(); $o = $null
foreach ($ln in [IO.File]::ReadAllLines("$root\source\$($cfg.file)")) {
$l = $ln.Trim()
if (-not $l -or $l.StartsWith('#')) { continue }
if ($l -match '^=\s*(\d+)') { if ($o) { $lessons += $o }; $o = [ordered]@{ n = [int]$Matches[1]; pic = 'π'; picText = ''; words = @(); letters = @(); lines = @() }; continue }
if ($l -match '^words:\s*(.*)$') { $o.words = @($Matches[1] -split '\s+' | Where-Object { $_ }); continue }
if ($l.StartsWith('?')) { $o.note = $l.TrimStart('?', ' '); continue }
$o.lines += $l
}
if ($o) { $lessons += $o }
foreach ($o in $lessons) {
if (-not $o.words.Count) { # no list printed: spell the lesson's own words
$w = @(); foreach ($m in [regex]::Matches(($o.lines -join ' '), "[A-Za-z]+(?:'[A-Za-z]+)?")) { $x = if ($m.Value -ceq 'I') { 'I' } else { $m.Value.ToLower() }; if ($w -notcontains $x) { $w += $x } }
$o.words = @($w | Select-Object -First 12)
}
$txt0 = $o.lines -join ' '
foreach ($key in $pics.Keys) { if ($txt0 -match "(?i)\b$key(?:s|es)?\b") { $o.pic = $pics[$key]; break } }
}
$out = [ordered]@{ title = $cfg.title; sub = $cfg.sub; color = $cfg.color; spell = $true; source = $cfg.source; lessons = $lessons }
[IO.File]::WriteAllText("$root\public\books\$Book.json", ($out | ConvertTo-Json -Depth 6), (New-Object Text.UTF8Encoding $false))
"$($lessons.Count) lessons"
$lessons | ForEach-Object { "{0,2} {1} w[{2}] lines={3} {4}" -f $_.n, $_.pic, ($_.words -join ' '), $_.lines.Count, $(if ($_.note) { '? ' + $_.note } else { '' }) }
return
}
if (-not (Test-Path "$root\source\$($cfg.file)")) {
New-Item -ItemType Directory -Force "$root\source" | Out-Null
Invoke-WebRequest "https://www.gutenberg.org/cache/epub/$($cfg.gutenberg)/pg$($cfg.gutenberg).txt" -OutFile "$root\source\$($cfg.file)" -UseBasicParsing
}
$txt = [IO.File]::ReadAllText("$root\source\$($cfg.file)") -replace "`r", ''
$headRx = '(?m)^(?:Lesson 1|LESSON [IVXLC]+\.{0,2}(?:\s*-*\s*REVIEW\.?)?)\s*$' # Third Reader has one "LESSON XXX.."
if ($cfg.headRx) { $headRx = $cfg.headRx }
$body = if ($cfg.start) { $txt.Substring($txt.LastIndexOf($cfg.start)) } else { $txt.Substring([regex]::Match($txt, $headRx).Index) }
$body = $body.Substring(0, $body.IndexOf($cfg.end))
$body = $body -replace '(?m)^SLATE WORK\.?\s*$', ''
if ($cfg.prose) {
# printed page headers ("18 ECLECTIC SERIES.") and page numbers, with the blank lines around them, so sentences join across pages
$body = [regex]::Replace($body, "\n[ \t]*\n?[ \t]*\d*[ \t]*(?:$($cfg.pageRx))\.?[ \t]*\d*[ \t]*\n(?:[ \t]*\n)*", "`n")
$body = $body -replace '(?m)^[ \t]*\(\d+\)[ \t]*\n', '' -replace '(?m)^[ \t]*\d+[,.]?(?:[ \t]*\d+[,.]?)*[ \t]*\n', ''
$body = $body -creplace "(?m)^[ \t]*\d*[ \t]*(?:$($cfg.pageRx))[.,]?[ \t]*\d*[ \t]*\n", '' # leftover headers ("62 ECLECTIC SERIES,")
$body = $body -creplace "(\w) n't\b", '$1n''t' -creplace "'T is\b", "'Tis" # old spelling: "do n't" -> "don't"
}
$heads = [regex]::Matches($body, $headRx)
# the text of each lesson runs from the end of its heading to the next heading (Regex.Split would also return captured groups)
$parts = @($body.Substring(0, $heads[0].Index))
for ($i = 0; $i -lt $heads.Count; $i++) { $s = $heads[$i].Index + $heads[$i].Length; $e = if ($i + 1 -lt $heads.Count) { $heads[$i + 1].Index } else { $body.Length }; $parts += $body.Substring($s, $e - $s) }
$sounds = 'th','sh','ch','ng','ew','oy','ow','wh','ou','oo','sk','qu','oi','gh','ck'
$romanVal = @{ I=1; V=5; X=10; L=50; C=100 }
# scanned "LVll." = LVII, so a lowercase l counts as I
function Roman($r) { if ($r -match '^\d+$') { return [int]$r }; $r = $r -creplace 'l', 'I'; $t = 0; for ($i = 0; $i -lt $r.Length; $i++) { $v = $romanVal["$($r[$i])"]; if ($i + 1 -lt $r.Length -and $romanVal["$($r[$i+1])"] -gt $v) { $t -= $v } else { $t += $v } }; $t }
function Sentences($para) {
# split a paragraph into kid-sized sentences; keep quotes and "--" asides with their sentence
$p = (($para -replace '\s+', ' ').Trim()) -creplace '^(?:\d+|I)\.\s+(?=["A-Z])', ''
$out = [regex]::Split($p, '(?<!\b(?:Mr|Mrs|Dr|St)\.)(?<=[.!?]"?)\s+(?=["A-Z])') | Where-Object { $_ }
# re-join pieces that split inside a quotation
$merged = @(); $buf = ''
foreach ($s in $out) { $buf = if ($buf) { "$buf $s" } else { $s }; if ((($buf -split '"').Count - 1) % 2 -eq 0) { $merged += $buf; $buf = '' } }
if ($buf) { $merged += $buf }
$merged | ForEach-Object { $s = $_.Trim(); if ($s -match '[A-Za-z]$') { $s += '.' }; $s }
}
# vocabulary words carry accent marks and syllable breaks in the later lessons: cun'ning, be cause', did n't
function IsPoem($para) {
$ls = @($para.Trim() -split '\n' | Where-Object { $_.Trim() })
# poem lines are short and each starts with a capital; wrapped prose goes on in lowercase
if ($para -match '\n\s+\S') { return $true }
if ($ls.Count -lt 2 -or ($ls | Measure-Object -Property Length -Maximum).Maximum -gt 55) { return $false }
$low = @($ls | Where-Object { $_.Trim() -cnotmatch '^["''\u2018\u201c(]{0,2}[A-Z]' }).Count
($low -eq 0) -or ($ls.Count -ge 4 -and $low -le 1)
}
function CleanWord($t) {
$t = $t -replace '\s+', ''
if ($t -match "(n't|'s|'ll|'ve|'re|'d|'m)$" -or $t -match "^'tis$") { return ($t -replace "'(?=.*')", '') } # keep a real contraction's apostrophe
$keep = $t.TrimEnd("'")
if ($keep -match "^(o'clock|o'er|ne'er|e'er|e'en)$") { return $keep } # old poetic words keep their apostrophe
$t -replace "'", ''
}
function VocabTokens($ln) {
$ln = $ln -replace '\s*\([^)]*\)', '' # pronunciation notes: (wuns), (a gen'), (as f)
$cols = if ($ln -match '\S\s{2,}\S') { $ln.Trim() -split '\s{2,}' } else { $ln.Trim() -split '\s+' }
foreach ($c in $cols) {
if (-not $c) { continue }
if ($c -match '\s') {
if ($c -match "'") { CleanWord $c } # syllables of one word: be cause'
elseif ($c -match '^(a|an|the)\s') { } # phrase like "a black hen": not a new word
else { $c -split '\s+' | ForEach-Object { CleanWord $_ } } # two words in one column: night owl
} else { CleanWord $c }
}
}
$nums = @()
if ($cfg.headRx) {
# A real lesson heading has a page number ("(74)") or is the next number in order. That skips a lesson's own
# numbered parts ("II. SUMMER.") and survives misprinted numerals ("XVV.", a second "LXX."): lessons are numbered in order.
$keep = @(); $prev = 0
# up to 2 lessons may be missing between two headings; a heading with a page number but a misprinted numeral is the next lesson
foreach ($m in $heads) {
$v = Roman $m.Groups[1].Value
if ($v -ge $prev + 1 -and $v -le $prev + 3) { $keep += $m; $prev = $v; $nums += $v }
# a misprinted numeral is still near its place (XVV for XXV); a lesson's own small parts ("III. A GOOD NAME. (95)" inside lesson 12) are not
elseif ($m.Value -match '\(\d+\)\s*$' -and $v -ge $prev - 5) { $keep += $m; $prev++; $nums += $prev }
}
$heads = $keep
$parts = @($body.Substring(0, $heads[0].Index))
for ($i = 0; $i -lt $heads.Count; $i++) { $s = $heads[$i].Index + $heads[$i].Length; $e = if ($i + 1 -lt $heads.Count) { $heads[$i + 1].Index } else { $body.Length }; $parts += $body.Substring($s, $e - $s) }
}
# authors from the table of contents: "87. The Best Capital Louisa M. Alcott 245"
$tocAuthors = @{}
# (page column optional: the Fifth Reader's contents have none); the first entry for a number wins, which is the contents page
$tocText = $txt.Substring(0, $txt.LastIndexOf($(if ($cfg.start) { $cfg.start } else { 'x' })) + 1)
if ($cfg.tocEnd) { $ci = $tocText.IndexOf($(if ($cfg.tocStart) { $cfg.tocStart } else { 'CONTENTS' })); $tocText = $tocText.Substring($ci, $tocText.IndexOf($cfg.tocEnd, $ci) - $ci) } # only the contents page, not the chapters on reading aloud
foreach ($m in [regex]::Matches($tocText, '(?m)^\s*(\d+)\.\s+(\S.*?\S)\s{2,}(\S.*?\S)(?:\s{2,}(\d+))?\s*$')) {
$a = (($m.Groups[3].Value -split '\s{2,}')[-1] -replace '\.?\s*\d+$', '').TrimEnd('.') # "Longfellow.290": page number stuck to the name
if ($a -notmatch '^\d' -and -not $tocAuthors.ContainsKey([int]$m.Groups[1].Value)) { $tocAuthors[[int]$m.Groups[1].Value] = $a } # skip a line that holds two entries
}
$lessons = @()
for ($k = 0; $k -lt $heads.Count; $k++) {
$h = $heads[$k].Value.Trim()
$n = if ($h -eq 'Lesson 1') { 1 } else { Roman(([regex]::Match($h, 'LESSON ([IVXLC]+)')).Groups[1].Value) }
$headTitle = ''
if ($cfg.headRx) {
$hm = [regex]::Match($h, $cfg.headRx); $n = $nums[$k]
$headTitle = (Get-Culture).TextInfo.ToTitleCase($hm.Groups[2].Value.Trim().TrimEnd('.').Trim('"', [char]0x201c, [char]0x201d).TrimEnd('.').ToLower()) -creplace "(\w) N't", '$1n''t' -replace '\(Concluded\.?\)', '(concluded)' -replace '\(Continued\.?\)', '(continued)'
}
$review = $h -match 'REVIEW'
$block = $parts[$k + 1]
$block = $block -replace '\[Footnote:[^\]]*\]', '' -replace '(?m)^\s*(\d+|[IVX]+)\.\s*$', ''
# a title line glued to its poem ("ALICE'S SUPPER." then the verses) gets its own paragraph
$block = $block -creplace '(?m)^([A-Z][A-Z .,''\u2019!-]{3,})$\n(?=\S)', "`$1`n`n"
$author = ''; $defs = @(); $questions = @()
# DEFINITIONS / EXERCISES / NOTES at the end of a lesson (Fourth Reader)
$tm = [regex]::Match($block, '(?m)^[ \t]*(?:DEFINITIONS|EXERCISES|NOTES?)\b')
if ($tm.Success) {
$tail = $block.Substring($tm.Index); $block = $block.Substring(0, $tm.Index)
foreach ($sec in [regex]::Matches($tail, '(?s)(DEFINITIONS|EXERCISES|NOTES?)\.?\s*-*(.*?)(?=(?:\n[ \t]*(?:DEFINITIONS|EXERCISES|NOTES?)\b)|\z)')) {
$txt2 = ($sec.Groups[2].Value -replace '\s+', ' ').Trim()
if ($sec.Groups[1].Value -eq 'DEFINITIONS') {
$txt2 = $txt2 -replace '^In defining words, that meaning is given which is appropriate to them in the connection in which they are used\.\s*', ''
foreach ($ln in (($txt2 -replace '(?:^|\s)\d+\.\s+', "`n") -split "`n")) {
foreach ($entry in [regex]::Split($ln.Trim(), '(?<=\.)\s+(?=[A-Z][\w''/\-]*(?: [\w''/\-]+){0,2},\s)')) {
$cm = [regex]::Match($entry, '^([A-Z][\w''/\-]*(?: [\w''/\-]+){0,2}),\s+(.+?)\.?\s*$')
if ($cm.Success) {
$w = $cm.Groups[1].Value -replace '[''/\-]', ''
$d = ($cm.Groups[2].Value -replace '(?<=[a-z])-(?=[a-z])', '').Trim()
$defs += ,@($w, $d)
}
}
}
} elseif ($sec.Groups[1].Value -eq 'EXERCISES') {
$questions += @([regex]::Split($txt2, '(?<=\?)\s+') | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '\?$' })
}
}
}
if ($cfg.prose) {
# the author's name under a poem: "John G. Whittier.", "Mrs. Follen."
$am = [regex]::Match($block, '\n[ \t]*((?:Mrs?\.\s+)?(?:[A-Z]\.\s*|[A-Z][a-z]+\s+){0,3}[A-Z][a-z]+\.)\s*$')
if ($am.Success) { $author = $am.Groups[1].Value.TrimEnd('.'); $block = $block.Substring(0, $am.Index) }
$block = $block -creplace '(?m)^(?=[ \t]*\d+\.\s+\S)', "`n" # each numbered paragraph is its own paragraph
foreach ($sp in ([regex]::Matches($block, '(?m)^([A-Z][a-z]+)\.\s+(?=["A-Z])') | Group-Object { $_.Groups[1].Value } | Where-Object { $_.Count -ge 2 -and $_.Name -notmatch '^(Mr|Mrs|Dr|St)$' })) {
$block = $block -creplace "(?m)^$($sp.Name)\.\s+", "`n$($sp.Name): " # a play: "Rose. See how..." -> "Rose: See how..."
}
}
# pull out illustrations (may span lines); script exercises become the cursive copy work
$script = @(); $picText = ''
foreach ($m in [regex]::Matches($block, '\[Illustration:([^\]]*)\]')) {
$c = $m.Groups[1].Value
if ($c -match '^\s*Script [Ee]xercise:') {
$c = $c -replace '^\s*Script [Ee]xercise:', ''
$paras = [regex]::Split($c.Trim(), '\n\s*\n')
foreach ($pp in $paras) {
if (IsPoem $pp) { $script += ($pp -split '\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } # poem: keep lines
else { $script += Sentences $pp }
}
} elseif (-not $picText) { $picText = ($c -replace '\s+', ' ').Trim().TrimEnd('.') }
}
$block = [regex]::Replace($block, '\[Illustration:[^\]]*\]', "`n`n@@PIC@@`n`n")
$paras = [regex]::Split($block.Trim(), '\n\s*\n') | ForEach-Object { $_.TrimEnd() } | Where-Object { $_.Trim() }
$words = @(); $letters = @(); $lines = @(); $inText = $false; $first = $true; $title = ''; $about = ''
foreach ($p0 in $paras) {
if ($p0.Trim() -eq '@@PIC@@') { if ($words.Count -or $letters.Count) { $inText = $true }; continue }
$p = $p0 -replace '\s*\([^)]*\)', ''
$isVocab = -not $cfg.prose -and -not $inText -and (($p -replace '\b(Mr|Mrs|Dr|St)\.', '$1') -notmatch '[.!?;,"]') -and ($first -or $p -match '\S\s{2,}\S' -or $p -match '^\S+$')
# phrase rows like "a rat a cat" come after the picture, not before it
if ($isVocab -and $words.Count -and $p -match '^\s*(a|the|an)\s\w+\s{3,}') { $isVocab = $false }
if ($isVocab) {
foreach ($ln in ($p0 -split '\n')) {
foreach ($t in (VocabTokens $ln)) {
if (-not $t) { continue }
$isSound = ($sounds -contains $t) -or ($t.Length -eq 1 -and -not ($first -and ($t -ceq 'a' -or $t -cmatch '^[A-Z]$')) -and $t -cne 'I')
if ($isSound) { if ($letters -notcontains $t) { $letters += $t } } elseif ($words -notcontains $t) { $words += $t }
}
$first = $false
}
continue
}
$inText = $true
# a note about the author before the selection ("By Celia Thaxter, who was born...", "Hester Lynch Thrale. 1739--1821, ...")
if ($cfg.headRx -and -not $lines.Count -and -not $about -and $p -match '\bborn\b|\d{4}\s*-{1,2}\s*\d{2,4}') {
$ta = if ($tocAuthors.ContainsKey([int]$n)) { ($tocAuthors[[int]$n] -split '\s+')[-1] } else { '' }
if ($p -match '^\s*By\s' -or ($ta -and $p -match "\b$([regex]::Escape($ta))\b")) { $about = ($p -replace '\s+', ' ').Trim(); continue }
}
if ($p.Trim() -cmatch '^[A-Z][A-Za-z'' ]{2,40}$' -and -not $lines.Count -and $p.Trim() -cmatch '^(?:(?:[A-Z][a-z'']*|a|an|and|the|of|in|on|at|to|for|with|by)\s?)+$' -and $p.Trim() -match '\s') { $title = $p.Trim(); continue }
if ($p.Trim() -cmatch '^[A-Z][A-Z .,''\u2019!?-]+$' -and ($cfg.prose -or $p -notmatch '\?')) { $title = (Get-Culture).TextInfo.ToTitleCase($p.Trim().TrimEnd('.').ToLower()); continue } # story title: THE WHITE KITTEN.
if (IsPoem $p) { $lines += ($p -split '\n' | ForEach-Object { ($_.Trim()) -replace '^\d+\.\s+', '' } | Where-Object { $_ }) } # poem
elseif ($p -notmatch '[.!?;,"]' -and $p -match '\S\s{3,}\S') { $lines += ($p.Trim() -split '\s{3,}') } # phrase row
else { $lines += Sentences $p }
}
if (-not $lines.Count) { $lines = $script }
$pic = ''; foreach ($key in $pics.Keys) { if ($picText -match "(?i)$key") { $pic = $pics[$key]; break } }
if ($cfg.pics.ContainsKey([int]$n)) { $pic = $cfg.pics[[int]$n] }
if (-not $pic -and $cfg.prose) { foreach ($key in $pics.Keys) { if ("$title $(($lines | Select-Object -First 3) -join ' ')" -match "(?i)\b$key(?:s|es)?\b") { $pic = $pics[$key]; break } } } # no picture notes: guess from the title
if (-not $pic) { $pic = if ($review -or $cfg.prose) { 'π' } else { 'πΌοΈ' } }
$o = [ordered]@{ n = $n; pic = $pic; picText = $(if ($picText) { $picText } elseif ($review) { 'Review' } else { '' }); words = @($words); letters = @($letters); lines = @($lines) }
if ($review) { $o.review = $true }
if ($headTitle) { $title = $headTitle } # the heading's title wins over part titles inside the lesson ("IV. WINTER.")
if ($defs.Count) {
# the book's own new words: shown in lowercase unless the lesson only ever writes them with a capital (names)
$alltext = $lines -join ' '
$o.defs = [ordered]@{}
foreach ($dd in $defs) {
$w = $dd[0]; if ($alltext -cmatch "\b$([regex]::Escape($w.ToLower()))\b" -or -not ($alltext -cmatch "\b$([regex]::Escape($w))\b")) { $w = $w.ToLower() }
if (-not $o.defs.Contains($w)) { $o.defs[$w] = $dd[1] }
}
$o.words = @($o.defs.Keys | Select-Object -First 12)
}
if ($questions.Count) { $o.questions = @($questions) }
if ($title) { $o.title = $title }
if ($cfg.headRx -and $tocAuthors.ContainsKey([int]$n)) { $author = $tocAuthors[[int]$n] } # the contents page knows best
if ($author -match 'Reader|EXAMPLES') { $author = '' }
if ($author) { $o.author = $author }
if ($about) { $o.about = $about }
if ($script.Count) { $o.script = @($script) }
if (-not $lines.Count) { Write-Host "Lesson $n has no text in Gutenberg's copy ($title), skipped" -ForegroundColor Yellow; continue }
$lessons += $o
}
if ($cfg.prose) {
# New words = words not met in the earlier books or earlier lessons, in reading order, up to 10 per lesson
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
$wordRx = "[A-Za-z]+(?:'[A-Za-z]+)?"
foreach ($prev in $cfg.after) {
$pj = Get-Content "$root\public\books\$prev.json" -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($PL in $pj.lessons) { foreach ($s in @($PL.lines) + @($PL.words) + @($PL.script)) { foreach ($m in [regex]::Matches("$s", $wordRx)) { [void]$seen.Add($m.Value) } } }
}
foreach ($o in $lessons) {
$text = (@($o.lines) + @($o.script)) -join ' '
$new = @()
foreach ($m in [regex]::Matches($text, $wordRx)) {
$w = $m.Value
if ($w.Length -lt 2 -or $w -match "^n'" -or -not $seen.Add($w)) { continue }
# names keep their capital (capitalized in the middle of a line); other words are shown in lowercase
if (-not ($text -cmatch "(?:[a-z]|\b(?:Mr|Mrs|Miss|Dr)\.)\s+$([regex]::Escape($w))\b") -or ($text -cmatch "\b$([regex]::Escape($w.ToLower()))\b")) { $w = $w.ToLower() }
$new += $w
}
if (-not @($o.words).Count) { $o.words = @($new | Select-Object -First 10) } # the book's own word list wins when it has one
}
}
# Last clean-up of every line: printed page headers that ended up inside a sentence ("... ECLECTIC SERIES. ..."),
# footnote/section marks (#, *, [ ]), and scanned digits ("l0" = 10, "I1" = 11)
function CleanLine($s) {
$s = $s -creplace '\d*\s*\b(?:ECLECTIC (?:SERIES|READER)|(?:FIRST|SECOND|THIRD|FOURTH|FIFTH|SIXTH) READER)\b[.,]?\s*\d*', ' ' -replace '[#*\[\]]', ''
$s = $s -creplace '\bI1\b', 'I' -creplace '(?<=\d)l|l(?=\d)', '1' -creplace '^\d+\s+(?=[a-z])', '' -creplace '(?<=[a-z]{3})\d\b', '' # footnote number on a word ("orthography1"); "I1 will" = I; "l0" = 10; a page number before the text
($s -replace '\s{2,}', ' ').Trim()
}
foreach ($o in $lessons) {
$o.lines = @($o.lines | ForEach-Object { CleanLine $_ } | Where-Object { $_ -match '[A-Za-z]' })
if ($o.script) { $o.script = @($o.script | ForEach-Object { CleanLine $_ } | Where-Object { $_ -match '[A-Za-z]' }) }
}
$out = [ordered]@{ title = $cfg.title; sub = $cfg.sub; color = $cfg.color; source = "Project Gutenberg #$($cfg.gutenberg) (public domain)"; lessons = $lessons }
$json = $out | ConvertTo-Json -Depth 6
New-Item -ItemType Directory -Force "$root\public\books" | Out-Null
[IO.File]::WriteAllText("$root\public\books\$Book.json", $json, (New-Object Text.UTF8Encoding $false))
"$($lessons.Count) lessons"
$lessons | ForEach-Object { "{0,2} {1} w[{2}] l[{3}] lines={4} script={5} | {6}" -f $_.n, $_.pic, ($_.words -join ' '), ($_.letters -join ' '), $_.lines.Count, $(if ($_.script) { $_.script.Count } else { 0 }), $_.lines[0] }
EOF
make-audio.ps1: also records every word's spelling (spell:is = "I, S. is.").
cat > /root/abcbooks-rebuild/make-audio.ps1 <<'EOF'
# Records every spoken text of a book with the PC's ETI-Eloquence (Reed) at each speed, then
# builds public/audio/<book>.json (text -> clip id + word start times). WAVs land in tools/out/<book>/s<speed>/.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\make-audio.ps1 [-Book primer1881]
# Then: ABCBooks\tools\push-audio.ps1 converts to MP3 on CT 306.
param([string]$Book = 'primer1881', [int[]]$Speeds = @(60, 75))
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$data = Get-Content "$root\public\books\$Book.json" -Raw -Encoding UTF8 | ConvertFrom-Json
$kids = 'Layla', 'Lavon', 'Lilly'
# key = what the app asks for; value = what Eloquence should say
$say = New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal) # case matters: "a cat" and "A cat" are different lines
function Add($key, $spoken) { if ($key -and -not $say.Contains($key)) { $say[$key] = $(if ($spoken) { $spoken } else { $key }) } }
# letter cards say the phonics sound (public/phonics.json, Eloquence phonetic code), not the letter name
$phonics = Get-Content "$root\public\phonics.json" -Raw -Encoding UTF8 | ConvertFrom-Json
function LetterName($t) {
$p = $phonics.PSObject.Properties[$t.ToLower()]
if ($p) { return ('`[.1' + $p.Value[0] + ']') } # single quotes: keep Eloquence's backquote
(($t.ToCharArray() | ForEach-Object { "$_".ToUpper() }) -join ', ') + '.'
}
foreach ($p in $phonics.PSObject.Properties) { if ($p.Name -notlike '_*') { Add "letter:$($p.Name)" (LetterName $p.Name); Add "example:$($p.Name)" $p.Value[1] } } # for phonics.html
foreach ($p in 'Tap each card to hear it.', 'Good! Now play the game.', 'Yes!', 'Good try!', 'Listen to the red words, then try again.',
'I did not hear you. Try again!', 'Start at the green dot!', 'Follow the arrow all the way.', 'Great job!', 'Write it on the board first!') { Add $p }
foreach ($k in $kids) { Add "Great job, $k!" }
# spoken instructions shared with the app (public/prompts.json)
$prompts = Get-Content "$root\public\prompts.json" -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($pr in $prompts.PSObject.Properties) {
if ($pr.Value -like '*{kid}*') { foreach ($k in $kids) { Add ($pr.Value -replace '\{kid\}', $k) } } else { Add $pr.Value }
}
foreach ($L in $data.lessons) { Add "lesson:$($L.n)" $(if ($L.review) { "Review. Lesson $($L.n)." } else { "Lesson $($L.n)." }) }
foreach ($L in $data.lessons) {
foreach ($t in $L.letters) { Add "letter:$t" (LetterName $t) }
foreach ($w in $L.words) { Add $w; Add "Find $w" "Find the word, $w." }
$copy = @()
if ($L.letters.Count) { $copy += ($L.letters -join ' '); Add ($L.letters -join ' ') ((($L.letters | ForEach-Object { LetterName $_ }) -join ', ')) }
if ($L.words.Count) { $copy += ($L.words -join ' ') }
$wl = @($L.words); for ($i = 0; $i -lt $wl.Count; $i += 6) { $copy += ($wl[$i..([Math]::Min($i + 5, $wl.Count - 1))] -join ' ') } # tracing pages, 6 words each (app.js WORDS_PER_PAGE)
foreach ($ln in $L.lines) {
Add $ln
foreach ($w in ($ln -split '\s+')) { $c = $w -replace '[^A-Za-z'']', ''; if ($c) { Add $c } } # single words for tap-to-hear + retries
if (($ln -split ' ').Count -gt 2) { Add "Write: $ln" }
}
foreach ($ln in $L.script) { Add $ln }
if ($L.defs) { foreach ($d in $L.defs.PSObject.Properties) { Add "def:$($d.Name)" "$($d.Name). $($d.Value)." } } # word + its meaning (Fourth Reader)
foreach ($q in @($L.questions)) { if ($q) { Add $q } }
if ($data.spell) { # Spell & Read: each word spelled with letter names, then said: "I, S. is."
foreach ($m in [regex]::Matches(($L.lines -join ' '), "[A-Za-z]+(?:'[A-Za-z]+)?")) {
$w = if ($m.Value -ceq 'I') { 'I' } else { $m.Value.ToLower() }
Add "spell:$w" ((($w.ToCharArray() | Where-Object { $_ -ne [char]39 } | ForEach-Object { "$_".ToUpper() }) -join ', ') + ". $w.")
}
} # Talk about it questions
foreach ($c in $copy) { Add $c }
}
"$($say.Count) clips per speed"
$sha = [Security.Cryptography.SHA1]::Create()
function Id($s) { (($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($s)) | Select-Object -First 6 | ForEach-Object { $_.ToString('x2') }) -join '') }
$items = New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)
$ps32 = 'C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
foreach ($sp in $Speeds) {
$dir = "$root\tools\out\$Book\s$sp"; New-Item -ItemType Directory -Force $dir | Out-Null
$jobs = @()
foreach ($k in $say.Keys) {
$id = Id $say[$k]
if (-not $items.Contains($k)) { $items[$k] = [ordered]@{ id = $id } }
if (-not (Test-Path "$dir\$id.wav.json")) { $jobs += [ordered]@{ text = $say[$k]; out = "$dir\$id.wav" } }
}
"speed $sp : $($jobs.Count) to record"
if ($jobs.Count) {
$jf = "$dir\jobs.json"; ConvertTo-Json @($jobs) | Set-Content -Encoding utf8 $jf
& $ps32 -NoProfile -ExecutionPolicy Bypass -File "$root\tools\eloq-render.ps1" -Jobs $jf -Speed $sp | Out-Null
}
foreach ($k in $say.Keys) {
$j = Get-Content "$dir\$($items[$k].id).wav.json" -Raw | ConvertFrom-Json
$items[$k]["t$sp"] = @($j.words)
}
}
$out = [ordered]@{ speeds = $Speeds; items = $items }
New-Item -ItemType Directory -Force "$root\public\audio" | Out-Null
[IO.File]::WriteAllText("$root\public\audio\$Book.json", ($out | ConvertTo-Json -Depth 5 -Compress), (New-Object Text.UTF8Encoding $false))
"manifest: $root\public\audio\$Book.json"
EOF
app.js: the app with the Spell it step.
cat > /root/abcbooks-rebuild/app.js <<'EOF'
// ABC Books: gamified McGuffey lessons. All progress is saved per kid in localStorage ('abcbooks').
const BOOKS = {};
const App = (() => {
const $ = id => document.getElementById(id);
const KIDS = ['Layla', 'Lavon', 'Lilly'];
let P = {}; // spoken instructions (prompts.json, also recorded by tools/make-audio.ps1)
const SHELF = [
{ id: 'primer1881' },
{ id: 'first1879' },
{ id: 'second1879' },
{ id: 'third1879' },
{ id: 'fourth1879' },
{ id: 'fifth1879' },
{ id: 'sixth1879' },
{ id: 'spell1867' }
];
const STEPS = [
{ id: 'words', name: 'New words', icon: 'A' },
{ id: 'spell', name: 'Spell it', icon: 'π€' },
{ id: 'listen', name: 'Listen', icon: 'π' },
{ id: 'read', name: 'Your turn to read', icon: 'π€' },
{ id: 'talk', name: 'Talk about it', icon: 'π¬' },
{ id: 'print', name: 'Trace: print', icon: 'a' },
{ id: 'script', name: 'Trace: cursive', icon: 'πΆ' },
{ id: 'board', name: 'Blackboard', icon: 'β' }
];
// ---------- storage ----------
let S = load();
function load() {
let s = null; try { s = JSON.parse(localStorage.getItem('abcbooks')); } catch (e) {}
s = s || { kid: null, progress: {}, boards: {} };
if (!s.speed) s.speed = 60;
if (!s.kid) { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg')); if (c && KIDS.includes(c.name)) s.kid = c.name; } catch (e) {} }
return s;
}
function save() { try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e) { trimBoards(); try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e2) {} } }
function trimBoards() { for (const k in S.boards) S.boards[k] = S.boards[k].slice(-12); }
const prog = (book) => ((S.progress[S.kid] = S.progress[S.kid] || {})[book] = (S.progress[S.kid] || {})[book] || {});
// ---------- sound + voice ----------
let AC;
function beep(good) {
try {
AC = AC || new (window.AudioContext || window.webkitAudioContext)();
const o = AC.createOscillator(), g = AC.createGain(), t = AC.currentTime;
o.type = 'triangle'; o.connect(g); g.connect(AC.destination);
const notes = good ? [523, 659, 784] : [220, 180];
notes.forEach((f, i) => o.frequency.setValueAtTime(f, t + i * 0.1));
g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + notes.length * 0.1 + 0.2);
o.start(t); o.stop(t + notes.length * 0.1 + 0.25);
} catch (e) {}
}
const hasTTS = 'speechSynthesis' in window;
let voice = null;
function pickVoice() {
if (!hasTTS) return;
const v = speechSynthesis.getVoices().filter(v => /^en/i.test(v.lang));
voice = v.find(v => /female|samantha|zira|aria|jenny|google us/i.test(v.name)) || v[0] || null;
}
if (hasTTS) { pickVoice(); speechSynthesis.onvoiceschanged = pickVoice; }
// Recorded Eloquence clips (tools/make-audio.ps1): audio/<book>.json maps text -> clip id + word start times per speed.
const AUDIO = {};
const clip = new Audio(); clip.preload = 'auto';
let clipDone = null, clipRaf = 0, unlocked = false, seqTok = 0, playTok = 0;
function stopAll() { seqTok++; stopSpeech(); } // stop anything playing, including a word-by-word sequence
// ---------- sound it out (phonics chunks, recorded by tools/make-soundouts.ps1) ----------
// The word big on screen, letter teams (st, sh, igh, ing...) in red, a silent e grey; Eloquence says the word, then each
// chunk's phonics sound while the highlight moves slowly from chunk to chunk.
const SO = {};
const loadSO = b => (b in SO) ? SO[b] : (SO[b] = fetch(`audio/${b}.soundout.json`).then(r => r.ok ? r.json() : null).catch(() => null));
async function soundOut(word) {
const w = word.replace(/[^A-Za-z']/g, '').toLowerCase(); if (!w) return;
const m = await loadSO(bookId), e = m && m.words[w];
if (!e) return speak(word.replace(/[^A-Za-z']/g, ''), null, 0.7);
stopAll();
const ov = el('div', 'so'), row = el('div', 'sow'); ov.appendChild(row);
const spans = e.ch.map(([t, k]) => { const s = el('span', (t.length > 1 ? 'multi' : '') + (k === 'S' ? ' silent' : ''), t); row.appendChild(s); return s; });
ov.appendChild(el('div', 'hint', 'Listen: the word, then each sound. Tap to close.'));
ov.onclick = () => stopAll();
document.body.appendChild(ov);
const my = ++playTok;
try {
const u = await clipURL(`audio/so/s${m.speed}/${e.id}.mp3`);
if (my !== playTok) return;
await new Promise(res => {
clipDone = res; clip.muted = false; clip.src = u;
const tick = () => {
const ms = clip.currentTime * 1000; let cur = -1;
e.ch.forEach(([, , t], i) => { if (t >= 0 && ms >= t - 80) cur = i; });
spans.forEach((s, i) => s.classList.toggle('on', i === cur));
clipRaf = setTimeout(tick, 60); // a timer, not animation frames: keeps stepping even on a busy tablet
};
clip.onended = clip.onerror = () => { clearTimeout(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().then(tick).catch(() => clip.onended());
});
} catch (err) { } finally { setTimeout(() => ov.remove(), 500); }
}
// press and hold a word (about half a second) to sound it out; a quick tap still does what it did before
function holdToSound(node, word) {
let t = 0, held = false;
node.addEventListener('pointerdown', () => { held = false; clearTimeout(t); t = setTimeout(() => { held = true; soundOut(word); }, 550); });
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => node.addEventListener(ev, () => clearTimeout(t)));
node.addEventListener('click', e => { if (held) { e.stopImmediatePropagation(); e.preventDefault(); held = false; } }, true);
node.addEventListener('contextmenu', e => e.preventDefault());
}
function stopSpeech() {
cancelAnimationFrame(clipRaf); clearTimeout(clipRaf); clip.pause();
if (clipDone) { const d = clipDone; clipDone = null; d(); }
if (hasTTS) speechSynthesis.cancel();
}
// look in the open book's recordings first, then any other book's (the home screen has no book open yet)
function clipFor(key) {
for (const b of [bookId, ...Object.keys(AUDIO)]) {
const m = AUDIO[b]; if (!m) continue;
const c = m.items[key] || (!/\s/.test(key) && m.items[key.toLowerCase()]);
if (c) return { ...c, book: b };
}
}
const speedOf = b => AUDIO[b].speeds.includes(S.speed) ? S.speed : AUDIO[b].speeds[0];
function playClip(c, onWord) {
stopSpeech();
const sp = speedOf(c.book), t = c['t' + sp] || [];
const my = ++playTok;
return new Promise(res => {
clipDone = () => { onWord && onWord(-1); res(); };
clipURL(`audio/${c.book}/s${sp}/${c.id}.mp3`).then(u => {
if (my !== playTok) return;
clip.muted = false; clip.src = u;
const tick = () => {
const ms = clip.currentTime * 1000 + 40; let i = 0;
for (let k = 0; k < t.length - 1; k++) if (t[k] >= 0 && ms >= t[k]) i = k;
onWord(i); clipRaf = requestAnimationFrame(tick);
};
clip.onended = clip.onerror = () => { cancelAnimationFrame(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().then(() => { if (onWord) tick(); }).catch(() => clip.onended());
}).catch(() => { if (my === playTok && clipDone) { const d = clipDone; clipDone = null; d(); } });
});
}
// Clips are fetched whole and played from memory: works with the offline cache on every browser (Safari needs this).
const blobs = new Map();
function clipURL(url) {
if (!blobs.has(url)) blobs.set(url, fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }).then(b => URL.createObjectURL(b)));
const p = blobs.get(url); p.catch(() => blobs.delete(url)); return p;
}
// warm up a lesson's clips so the first tap answers right away
function preload(L) {
if (!AUDIO[bookId]) return;
[...L.lines, ...L.words, ...L.letters.map(t => 'letter:' + t)].forEach(k => { const c = clipFor(k); if (c) clipURL(`audio/${c.book}/s${speedOf(c.book)}/${c.id}.mp3`).catch(() => {}); });
}
// Safari/iOS only allows audio that starts inside a tap: play a muted clip on the first tap to unlock it.
window.addEventListener('pointerdown', () => {
const c = clipFor('Yes!'); if (unlocked || !c) return; unlocked = true;
const src = `audio/${c.book}/s${AUDIO[c.book].speeds[0]}/${c.id}.mp3`;
if (!clip.src) { clip.muted = true; clip.src = src; clip.play().then(() => { if (clip.src.endsWith(src)) { clip.pause(); } clip.muted = false; }).catch(() => { clip.muted = false; }); }
}, { passive: true });
// speak(text, onWord(i)) -> Promise: recorded clip if there is one, else one clip per word, else the device voice.
async function speak(text, onWord, rate) {
seqTok++;
const c = clipFor(text);
if (c) return playClip(c, onWord);
const parts = text.split(/[\s,]+/).map(w => w.replace(/[^A-Za-z']/g, '')).filter(Boolean);
if (parts.length && parts.every(w => clipFor(w))) {
const my = seqTok;
for (let i = 0; i < parts.length && my === seqTok; i++) { await playClip(clipFor(parts[i]), onWord ? j => onWord(j < 0 ? -1 : i) : null); }
return;
}
return ttsSpeak(text, onWord, rate);
}
// device voice fallback. Word highlight uses boundary events, or a timing guess when the device has none.
function ttsSpeak(text, onWord, rate) {
return new Promise(res => {
const words = text.split(/\s+/);
if (!hasTTS) { // no voice: still step through the words so the highlight works
let i = 0; const t = setInterval(() => { onWord && onWord(i); if (++i > words.length) { clearInterval(t); res(); } }, 550); return;
}
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text.replace(/;/g, ','));
if (voice) u.voice = voice; u.rate = rate || 0.8; u.pitch = 1.05;
let gotBoundary = false, guess;
const starts = []; let p = 0; for (const w of words) { starts.push(p); p += w.length + 1; }
u.onboundary = e => {
if (e.name && e.name !== 'word') return; gotBoundary = true; clearInterval(guess);
let i = starts.findIndex((s, k) => e.charIndex >= s && (k === starts.length - 1 || e.charIndex < starts[k + 1]));
onWord && i >= 0 && onWord(i);
};
u.onstart = () => {
if (!onWord) return; let i = 0; onWord(0);
guess = setInterval(() => { if (gotBoundary) return clearInterval(guess); if (++i < words.length) onWord(i); }, 620 / (rate || 0.8) * 0.8);
};
u.onend = u.onerror = () => { clearInterval(guess); onWord && onWord(-1); res(); };
speechSynthesis.speak(u);
});
}
// ---------- speech recognition ----------
// First choice: record the line and let our Vosk server (CT 307, api/recognize) check it. It only
// listens for the line's words, so misreads are caught instead of auto-corrected. Fallback: the browser's own.
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const GUM = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let useMic = GUM || !!SR; // turned off for good if the browser blocks the mic (Amazon Kids browser says not-allowed)
let useServer = GUM, lastUnknown = [], stopListening = () => {};
const SOUNDS_LIKE = { a: ['uh', 'ah', 'the'], ann: ['an', 'anne', 'and'], nat: ['gnat', 'matt', 'nate'], rat: ['ratt'], cat: ['kat'], rab: ['rob', 'rabb'] };
const norm = w => w.toLowerCase().replace(/[^a-z']/g, '');
function same(a, b) { return a === b || (SOUNDS_LIKE[a] || []).includes(b); }
// which expected words were heard, in order (LCS alignment)
function matchWords(expected, heard) {
const E = expected.map(norm), H = heard.map(norm).filter(Boolean);
const m = E.length, n = H.length, d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) d[i][j] = same(E[i], H[j]) ? d[i + 1][j + 1] + 1 : Math.max(d[i + 1][j], d[i][j + 1]);
const ok = new Array(m).fill(false); let i = 0, j = 0;
while (i < m && j < n) { if (same(E[i], H[j])) { ok[i] = true; i++; j++; } else if (d[i + 1][j] >= d[i][j + 1]) i++; else j++; }
return ok;
}
async function listen(timeoutMs, line) {
lastUnknown = [];
if (useServer) {
try { return await listenServer(timeoutMs, line); }
catch (e) { useServer = false; if (!SR) { useMic = false; return []; } } // server down: browser recognizer if any
}
return listenBrowser(timeoutMs);
}
// Records until the kid stops talking (1.3 s quiet), the time runs out, or the mic is tapped again.
async function listenServer(timeoutMs, line) {
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); }
catch (e) { useMic = false; return []; }
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try { await ctx.resume(); } catch (e) {}
const src = ctx.createMediaStreamSource(stream), proc = ctx.createScriptProcessor(4096, 1, 1);
const chunks = []; let spoke = false, quiet = 0; const t0 = performance.now();
await new Promise(res => {
let done = false; const fin = () => { if (!done) { done = true; res(); } };
stopListening = fin;
proc.onaudioprocess = e => {
const d = e.inputBuffer.getChannelData(0); chunks.push(new Float32Array(d));
let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i];
if (Math.sqrt(s / d.length) > 0.015) { spoke = true; quiet = 0; } else if (spoke) quiet += d.length / ctx.sampleRate * 1000;
if ((spoke && quiet > 1300) || performance.now() - t0 > timeoutMs) fin();
};
src.connect(proc); proc.connect(ctx.destination);
});
stopListening = () => {};
proc.disconnect(); src.disconnect(); stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate; ctx.close();
if (!spoke) return [];
const r = await fetch('api/recognize?words=' + encodeURIComponent(line), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: to16k(chunks, rate) });
if (!r.ok) throw new Error('speech ' + r.status);
const j = await r.json(); lastUnknown = j.unknown || [];
return [j.words || []];
}
// Float32 at the device rate -> 16 kHz 16-bit PCM (what Vosk wants)
function to16k(chunks, rate) {
const n = chunks.reduce((a, c) => a + c.length, 0), all = new Float32Array(n); let o = 0;
for (const c of chunks) { all.set(c, o); o += c.length; }
const ratio = rate / 16000, m = Math.floor(n / ratio), out = new Int16Array(m);
for (let i = 0; i < m; i++) {
const a = Math.floor(i * ratio), b = Math.min(n, Math.max(a + 1, Math.floor((i + 1) * ratio))); let s = 0;
for (let k = a; k < b; k++) s += all[k];
out[i] = Math.max(-1, Math.min(1, s / (b - a))) * 32767;
}
return out.buffer;
}
function listenBrowser(timeoutMs) {
return new Promise(res => {
const r = new SR(); r.lang = 'en-US'; r.interimResults = false; r.maxAlternatives = 3;
let best = [], done = false;
const fin = () => { if (done) return; done = true; try { r.stop(); } catch (e) {} res(best); };
r.onresult = e => { const alts = [...e.results[0]].map(a => a.transcript.split(/\s+/)); best = alts; fin(); };
r.onerror = e => { if (/not-allowed/.test(e.error || '')) useMic = false; fin(); };
r.onend = fin;
try { r.start(); } catch (e) { useMic = false; return fin(); }
setTimeout(fin, timeoutMs || 9000);
});
}
// ---------- helpers ----------
function toast(t, ms) { const el = $('toast'); el.textContent = t; el.style.display = 'block'; clearTimeout(toast.t); toast.t = setTimeout(() => el.style.display = 'none', ms || 1200); }
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function btn(label, fn, cls) { const b = el('button', cls, label); b.onclick = fn; return b; }
const shuffle = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const starStr = n => 'β
'.repeat(n) + 'β'.repeat(3 - n);
function go(id) { document.querySelectorAll('.screen').forEach(s => s.classList.toggle('on', s.id === id)); if (id === 'home') home(); }
// ---------- home + map ----------
function home() {
const k = $('kids'); k.innerHTML = '';
KIDS.forEach(n => k.appendChild(btn(n, () => { S.kid = n; save(); home(); speak(P.greet.replace('{kid}', n)); }, S.kid === n ? 'sel' : '')));
const sb = $('speedBtn'); sb.textContent = 'Voice: ' + (S.speed === 75 ? 'faster' : 'normal');
sb.onclick = () => { S.speed = S.speed === 75 ? 60 : 75; save(); home(); bookId = bookId || 'primer1881'; speak('Great job!'); };
// The books follow the kids as they grow: each shows this kid's progress, and the one they're on glows.
// Every book stays open, so an older kid can start at their own level.
const sh = $('shelf'); sh.innerHTML = '';
const doneIn = id => { const p = (S.progress[S.kid] || {})[id] || {}; return Object.values(p).filter(x => x && x.stars).length; };
const open = SHELF.filter(b => !b.soon && BOOKS[b.id]);
const cur = S.kid && (open.slice().reverse().find(b => doneIn(b.id) > 0 && doneIn(b.id) < BOOKS[b.id].lessons.length)
|| open.find(b => doneIn(b.id) < BOOKS[b.id].lessons.length));
SHELF.forEach(b => {
const B = BOOKS[b.id] || b;
const n = BOOKS[b.id] ? BOOKS[b.id].lessons.length : 0, d = S.kid && n ? doneIn(b.id) : 0;
const prog = n && S.kid ? (d >= n ? 'β
Finished!' : d ? `Lesson ${d + 1} of ${n}` : `Lessons: ${n}`) : '';
const e = btn(`<b>${B.title}</b><span class="bp">${prog}</span><small>${B.sub}</small>`, () => {
if (!S.kid) return toast('Pick your name first!');
if (b.soon || !BOOKS[b.id]) return toast('Coming soon!');
openBook(b.id);
}, 'book' + (b.soon ? ' soon' : '') + (cur && cur.id === b.id ? ' cur' : ''));
e.style.background = B.color; sh.appendChild(e);
});
}
let book, bookId;
function openBook(id) {
loadSO(id);
bookId = id; book = BOOKS[id]; $('mapTitle').textContent = `${S.kid}: ${book.title}`;
const t = $('tiles'); t.innerHTML = ''; const p = prog(id);
let nextSet = false;
book.lessons.forEach((L, i) => {
const st = (p[L.n] || {}).stars || 0, prevDone = i === 0 || (p[book.lessons[i - 1].n] || {}).stars;
const isNext = !st && prevDone && !nextSet; if (isNext) nextSet = true;
const tile = btn(`${L.review ? 'Review<br>' + L.n : L.n}<span class="st">${st ? starStr(st) : ''}</span>`, () => {
if (!prevDone && !st) return toast('Finish lesson ' + book.lessons[i - 1].n + ' first');
startLesson(L);
}, 'tile' + (isNext ? ' next' : '') + (!prevDone && !st ? ' locked' : '') + (L.review ? ' rev' : ''));
t.appendChild(tile);
});
go('map'); speak(P.map);
}
// ---------- lesson flow ----------
let L, stepIdx, scores, cleanup = null, sayAgain = null;
function stepsFor(L) { return STEPS.filter(s => (s.id !== 'words' || (L.words.length + L.letters.length)) && (s.id !== 'talk' || (L.questions && L.questions.length)) && (s.id !== 'spell' || book.spell)); }
function startLesson(lesson) {
L = lesson; scores = {}; stepIdx = 0; preload(L);
$('lesTitle').textContent = (L.review ? 'Review ' : 'Lesson ') + L.n;
go('lesson'); runStep();
}
function leave() { stopStep(); openBook(bookId); }
function stopStep() { stopAll(); if (cleanup) { cleanup(); cleanup = null; } }
function runStep() {
stopStep();
const steps = stepsFor(L), s = steps[stepIdx];
const dots = $('steps'); dots.innerHTML = '';
steps.forEach((x, i) => { const d = el('i', i < stepIdx ? 'done' : i === stepIdx ? 'cur' : '', x.icon); dots.appendChild(d); });
if (!s) return finish();
$('stepTitle').textContent = s.name; $('stage').innerHTML = ''; $('controls').innerHTML = ''; $('hint').textContent = '';
sayAgain = null; stepTok++;
const instr = s.id === 'read' ? (useMic ? P.readMic : P.readHelper) : P[s.id];
$('hint').textContent = instr;
introP = (stepIdx === 0 ? speak('lesson:' + L.n) : Promise.resolve()).then(() => stepTok === myTok && speak(instr));
const myTok = stepTok;
$('sayBtn').onclick = () => speak(instr).then(() => sayAgain && sayAgain());
({ words: stepWords, listen: stepListen, read: stepRead, talk: stepTalk, spell: stepSpell, print: () => stepTrace('print'), script: () => stepTrace('script'), board: stepBoard })[s.id]();
}
let introP = Promise.resolve(), stepTok = 0;
const afterIntro = fn => { const t = stepTok; introP.then(() => { if (t === stepTok) fn(); }); };
function next(stars) { scores[stepsFor(L)[stepIdx].id] = stars; stepIdx++; runStep(); }
// 1. New words: hear each card, then "find it" game
function stepWords() {
const items = [...L.letters.map(t => ({ t, letter: true })), ...L.words.map(t => ({ t }))];
const box = el('div', 'cards'); $('stage').appendChild(box);
const cards = items.map(it => { const c = btn(it.t, () => onTap(it, c), 'card' + (it.letter ? ' letter' : '')); if (!it.letter) holdToSound(c, it.t); box.appendChild(c); return c; });
let mode = 'learn', heardAll = new Set(), target = null, round = 0, misses = 0, busy = false;
const rounds = shuffle(L.words.length ? L.words : L.letters).concat(shuffle(L.words.length ? L.words : L.letters)).slice(0, 6);
$('hint').textContent = 'Tap each card to hear it.';
// a word with a meaning from the book (Fourth Reader DEFINITIONS) says the word and what it means
const def = it => !it.letter && L.defs && L.defs[it.t];
function say(it) { if (def(it)) $('hint').textContent = `${it.t}: ${def(it)}`; return speak(it.letter ? 'letter:' + it.t : def(it) ? 'def:' + it.t : it.t, null, 0.7); }
function onTap(it, c) {
if (mode === 'learn') { say(it); heardAll.add(it); c.classList.add('ok'); setTimeout(() => c.classList.remove('ok'), 500); if (heardAll.size === items.length) startGame(); return; }
if (busy) return;
if (it.t === target) { busy = true; beep(true); c.classList.add('ok'); setTimeout(() => { busy = false; c.classList.remove('ok'); nextRound(); }, 700); }
else { beep(false); misses++; c.classList.remove('no'); void c.offsetWidth; c.classList.add('no'); }
}
function startGame() {
$('controls').innerHTML = '';
$('controls').appendChild(btn('Play: Find it!', () => { mode = 'game'; $('controls').innerHTML = ''; speak(P.game).then(nextRound); }, 'big'));
$('hint').textContent = 'Good! Now play the game.'; speak('Good! Now play the game.');
}
function nextRound() {
if (round >= rounds.length) { busy = true; toast('Great job! β'); return setTimeout(() => next(misses === 0 ? 3 : misses < 3 ? 2 : 1), 900); }
target = rounds[round++]; $('hint').textContent = `Find the word (${round} of ${rounds.length})`;
sayAgain = () => speak('Find ' + target, null, 0.75); sayAgain();
shuffle(cards).forEach(c => box.appendChild(c));
}
}
// 2. Listen: the book is read to you with each word lit up
function stepListen() {
const st = $('stage'); const wrap = el('div', 'text');
wrap.appendChild(el('div', 'pic', `${L.pic}<small>${L.picText}</small>`));
if (L.title) wrap.appendChild(el('h2', '', L.title));
if (L.author) { const by = el('div', 'parent', 'by ' + L.author); if (L.about) by.title = L.about; wrap.appendChild(by); }
const lines = L.lines.map(t => { const d = el('div', 'ln'); t.split(' ').forEach(w => { const s = el('span', 'w', w); s.onclick = () => speak(w.replace(/[^A-Za-z']/g, ''), null, 0.7); holdToSound(s, w); d.appendChild(s); d.append(' '); }); wrap.appendChild(d); return d; });
st.appendChild(wrap);
let stop = false;
async function playAll() {
playB.disabled = true; stop = false;
for (const d of lines) {
if (stop) break;
d.classList.add('cur'); d.scrollIntoView({ block: 'nearest' });
const ws = [...d.querySelectorAll('.w')];
await speak(d.textContent.trim(), i => ws.forEach((w, k) => w.classList.toggle('hi', k === i)));
d.classList.remove('cur'); await new Promise(r => setTimeout(r, 350));
}
if (stop) return;
playB.disabled = false; nextB.disabled = false; $('hint').textContent = 'Tap a word to hear it. Press and hold a word to sound it out.';
}
const playB = btn('βΆ Read to me', playAll, 'big'), nextB = btn('Next βΆ', () => next(3));
nextB.disabled = true; $('controls').append(playB, nextB);
sayAgain = playAll; cleanup = () => { stop = true; stopAll(); };
if (!AUDIO[bookId] && !hasTTS) $('hint').textContent = 'This device has no reading voice. A grown-up can read it.';
afterIntro(playAll);
}
// 3. Your turn: kid reads each line; the app listens (or a grown-up marks it)
function stepRead() {
const st = $('stage'); const wrap = el('div', 'text'); st.appendChild(wrap);
let li = 0, total = 0, right = 0, tries = 0;
const lineEl = el('div', 'ln cur'); wrap.appendChild(lineEl);
const mic = btn('π€', go, 'mic');
function show() {
lineEl.innerHTML = '';
L.lines[li].split(' ').forEach(w => { const s = el('span', 'w', w); holdToSound(s, w); lineEl.append(s, ' '); });
$('hint').textContent = useMic ? `Line ${li + 1} of ${L.lines.length}: tap the microphone, then read it out loud.` : `Line ${li + 1} of ${L.lines.length}: read it out loud. Grown-up: tap words that were missed, then β.`;
$('controls').innerHTML = '';
if (useMic) $('controls').appendChild(mic);
else {
[...lineEl.querySelectorAll('.w')].forEach(w => { w.classList.add('ok'); w.onclick = () => { w.classList.toggle('ok'); w.classList.toggle('miss'); }; });
$('controls').appendChild(btn('β Done', () => grade([...lineEl.querySelectorAll('.w')].map(w => w.classList.contains('ok'))), 'big'));
}
$('controls').appendChild(btn('π Help', () => speak(L.lines[li])));
$('controls').appendChild(btn('Skip', () => next(1), 'ghost parent'));
}
async function go() {
if (mic.classList.contains('live')) return stopListening(); // second tap = "I'm done"
mic.classList.add('live');
const words = L.lines[li].split(' ');
const alts = await listen(Math.min(25000, 4000 + 700 * words.length), L.lines[li]);
mic.classList.remove('live');
if (!useMic) { $('hint').textContent = P.micBlocked; speak(P.micBlocked); return show(); }
let ok = new Array(words.length).fill(false);
for (const a of alts) { const m = matchWords(words, a); if (m.filter(Boolean).length > ok.filter(Boolean).length) ok = m; }
if (alts.length) ok = ok.map((v, i) => v || lastUnknown.includes(norm(words[i]))); // words the checker can't hear count as read
[...lineEl.querySelectorAll('.w')].forEach((w, i) => { w.classList.toggle('ok', ok[i]); w.classList.toggle('miss', !ok[i]); });
if (!alts.length) { toast('I did not hear you. Try again!', 1500); return; }
grade(ok);
}
function grade(ok) {
const n = ok.filter(Boolean).length; tries++;
if (n === ok.length || tries >= 2) {
total += ok.length; right += n; tries = 0;
if (n === ok.length) { beep(true); toast('Yes! β', 900); } else toast('Good try!', 900);
setTimeout(() => { if (++li >= L.lines.length) next(right / total > 0.9 ? 3 : right / total > 0.7 ? 2 : 1); else show(); }, 1000);
} else {
beep(false); $('hint').textContent = 'Listen to the red words sounded out, then try again.';
const words = L.lines[li].split(' ').filter((w, i) => !ok[i]);
(async () => { // each missed word sounded out, the phonics way
for (const w of words) { await soundOut(w); await new Promise(r => setTimeout(r, 400)); }
if (!useMic) show();
})();
}
}
sayAgain = () => speak(L.lines[li]);
show();
}
// 1b. Spell it (Spell & Read, 1867): "Let the child spell each word in the line, then read the line."
// Tapping a word plays its spelling ("I, S. is.") so a grown-up can check.
function stepSpell() {
const wrap = el('div', 'text'); $('stage').appendChild(wrap);
const box = el('div', 'cards'); box.style.height = 'auto'; wrap.appendChild(box);
let i = 0;
function show() {
box.innerHTML = '';
L.lines[i].split(' ').forEach(w => {
// a joined word ("new-cut", "lap-dog") is spelled one part after the other
const parts = w.split('-').map(p => p.replace(/[^A-Za-z']/g, '')).filter(Boolean);
if (!parts.length) return;
const sb = btn(w, async () => { for (const p of parts) await speak('spell:' + (p === 'I' ? p : p.toLowerCase()), null, 0.7); }, 'card');
holdToSound(sb, w); box.appendChild(sb);
});
$('hint').textContent = `Line ${i + 1} of ${L.lines.length}: spell each word out loud, then read the line.`;
}
$('controls').append(btn('π Line', () => speak(L.lines[i])), btn('Next line βΆ', () => { if (++i < L.lines.length) show(); else next(3); }, 'big'));
sayAgain = () => speak(L.lines[i]);
show();
}
// 3b. Talk about it: the book's own questions (Fourth Reader EXERCISES), read one at a time; the kid answers out loud
function stepTalk() {
const wrap = el('div', 'text'); $('stage').appendChild(wrap);
const q = el('div', 'ln cur'); wrap.appendChild(q);
let i = 0;
function show() {
q.textContent = L.questions[i];
$('hint').textContent = `Question ${i + 1} of ${L.questions.length}: answer it out loud.`;
sayAgain = () => speak(L.questions[i]); sayAgain();
}
$('controls').append(btn('Next question βΆ', () => { if (++i < L.questions.length) show(); else next(3); }, 'big'));
afterIntro(show);
}
// 4/5. Tracing pages: letters, words, then each sentence
// Copy work stays a sensible length in the long Readers: new words 6 to a page, up to 6 short sentences
// (tools/make-audio.ps1 records the same word pages).
const WORDS_PER_PAGE = 6;
function copySentences(max) {
const seen = new Set(), all = [];
L.lines.filter(t => t.split(' ').length > 2).forEach(t => { const k = t.toLowerCase(); if (!seen.has(k)) { seen.add(k); all.push(t); } });
const short = all.filter(t => t.length <= 70);
return (short.length ? short : all.slice(0, 1)).slice(0, max);
}
function copyItems(style) {
if (style === 'script' && L.script) return L.script.slice(0, 6);
const out = [];
if (L.letters.length) out.push(L.letters.join(' '));
for (let i = 0; i < L.words.length; i += WORDS_PER_PAGE) out.push(L.words.slice(i, i + WORDS_PER_PAGE).join(' '));
return out.concat(copySentences(6));
}
function stepTrace(style) {
const items = copyItems(style); let i = 0, misses = 0, tracer;
const box = el('div', 'grow paper'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); box.appendChild(cv);
ABCTrace.Tracer.onMiss = why => { misses++; beep(false); $('hint').textContent = why === 'start' ? 'Start at the green dot!' : 'Follow the arrow all the way.'; };
function page() {
if (tracer) tracer.stop();
$('hint').textContent = `Page ${i + 1} of ${items.length}: start at the green dot and follow the arrow.`;
sayAgain = () => speak(items[i]); cv.dataset.text = items[i];
if (i === 0) afterIntro(sayAgain); else sayAgain();
tracer =ABCTrace.Tracer(cv, items[i], style, () => {
beep(true); toast('β', 700);
setTimeout(() => { if (++i < items.length) page(); else next(misses < 3 ? 3 : misses < 8 ? 2 : 1); }, 800);
});
}
$('controls').appendChild(btn('Skip page', () => { if (++i < items.length) page(); else next(1); }, 'ghost parent'));
setTimeout(page, 50);
const onRes = () => page(); window.addEventListener('resize', onRes);
cleanup = () => { tracer && tracer.stop(); window.removeEventListener('resize', onRes); };
}
// 6. Blackboard: write it yourself in chalk
function stepBoard() {
const items = copySentences(3);
if (!items.length) items.push(L.lines[0]);
let i = 0, style = 'print', bb, total = 0;
const box = el('div', 'grow'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); cv.style.background = 'var(--board)'; box.appendChild(cv);
const styleB = btn('Print', () => { style = style === 'print' ? 'script' : 'print'; styleB.textContent = style === 'print' ? 'Print' : 'Cursive'; page(); });
$('controls').append(styleB, btn('π Peek', () => bb.peek(2500)), btn('βΆ', () => bb.undo()), btn('Erase', () => bb.erase()), btn('Done β', done, 'big'));
function page() {
if (bb) bb.stop();
bb = ABCTrace.Blackboard(cv, items[i], style);
$('hint').textContent = `Write: "${items[i]}" (${i + 1} of ${items.length})`;
sayAgain = () => speak('Write: ' + items[i]);
if (i === 0) afterIntro(sayAgain); else sayAgain();
}
function done() {
if (bb.empty()) return toast('Write it on the board first!');
const s = bb.score(); total += s;
const list = (S.boards[S.kid] = S.boards[S.kid] || []);
list.push({ book: bookId, n: L.n, text: items[i], style, img: bb.snapshot(), at: Date.now() });
if (list.length > 40) list.splice(0, list.length - 40);
save(); beep(true); toast(starStr(s), 1100);
setTimeout(() => { if (++i < items.length) page(); else next(Math.round(total / items.length)); }, 1200);
}
setTimeout(page, 50);
cleanup = () => bb && bb.stop();
}
function finish() {
const vals = Object.values(scores), stars = Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length));
const p = prog(bookId), old = (p[L.n] || {}).stars || 0;
p[L.n] = { stars: Math.max(old, stars), steps: scores, at: Date.now() }; save();
$('stepTitle').textContent = `${S.kid}, you finished ${L.review ? 'Review' : 'Lesson'} ${L.n}!`;
$('stage').innerHTML = `<div class="text"><div class="stars">${starStr(stars)}</div></div>`;
$('hint').textContent = ''; $('controls').innerHTML = '';
$('controls').append(btn('Lessons', () => openBook(bookId), 'big'));
beep(true); speak(`Great job, ${S.kid}!`).then(() => speak(P.done));
}
// ---------- gallery ----------
$('toGallery').onclick = () => {
const g = $('gal'); g.innerHTML = '';
const list = (S.boards[S.kid] || []).slice().reverse();
if (!list.length) g.innerHTML = '<p>No blackboard pictures yet.</p>';
list.forEach(b => { const f = el('figure'); f.style.margin = 0; const im = el('img'); im.src = b.img; f.append(im, el('figcaption', 'parent', `Lesson ${b.n} Β· ${b.style} Β· ${new Date(b.at).toLocaleDateString()}`)); g.appendChild(f); });
go('gallery');
};
// books + their recorded voice are JSON files, so the same lesson data feeds the app and the audio recorder
Promise.all([fetch('prompts.json').then(r => r.json()).then(j => { P = j; })].concat(SHELF.filter(b => !b.soon).map(b => Promise.all([
fetch(`books/${b.id}.json`).then(r => r.json()).then(j => { BOOKS[b.id] = j; }),
fetch(`audio/${b.id}.json`).then(r => r.ok ? r.json() : null).then(j => { if (j) AUDIO[b.id] = j; }).catch(() => {})
])))).then(home, () => toast('Could not load the books. Check the internet.', 4000));
window.addEventListener('pointerdown', () => { if (AC && AC.state === 'suspended') AC.resume(); }, { passive: true });
return { go, leave, debug: () => ({ S, L, stepIdx, scores }) };
})();
EOF
prompts.json: the spoken instructions, including "Spell it".
cat > /root/abcbooks-rebuild/prompts.json <<'EOF'
{
"home": "Who is reading? Tap your name. Then tap a book.",
"map": "Pick a lesson. The glowing one is next.",
"words": "New words. Tap each card to hear it.",
"game": "Now play the game. Listen, then tap the word you hear.",
"spell": "Spell it. Spell each word out loud, then read the line. Tap a word to hear how it is spelled.",
"listen": "Listen. I will read the lesson to you. Watch the words light up.",
"readMic": "Your turn to read. Tap the microphone, then read the line out loud.",
"readHelper": "Your turn to read. Read each line out loud to a grown-up. Grown-up, tap any word that was missed, then tap done.",
"micBlocked": "This device cannot listen. Read it to a grown-up instead.",
"print": "Time to trace. Start at the green dot, and follow the arrow.",
"script": "Now trace in cursive. Start at the green dot, and follow the arrow.",
"talk": "Talk about it. Listen to each question, and answer it out loud to a grown-up.",
"board": "Blackboard time. Write it yourself. Tap peek if you need help. Tap done when you finish.",
"done": "You finished the lesson!",
"greet": "Hi, {kid}! Tap a book to start."
}
EOF
arrange-rebuild.ps1: sorts the copied files into the project folders (now also source\).
cat > /root/abcbooks-rebuild/arrange-rebuild.ps1 <<'EOF'
# Rebuild helper: sorts the flat folder of files copied from the blog post into the ABCBooks layout.
# Usage: powershell -ExecutionPolicy Bypass -File <flat folder>\arrange-rebuild.ps1 -From <flat folder> -To C:\ABCBooks
param([Parameter(Mandatory)] [string]$From, [Parameter(Mandatory)] [string]$To)
foreach ($d in 'public\books', 'public\audio', 'tools', 'proxmox', 'source') { New-Item -ItemType Directory -Force "$To\$d" | Out-Null }
Get-ChildItem $From -File | ForEach-Object {
$dest = switch -Regex ($_.Name) {
'^(deploy\.ps1)$' { $To; break }
'\.ps1$' { "$To\tools"; break }
'\.sh$' { "$To\proxmox"; break }
'^spell1867\.txt$' { "$To\source"; break } # hand-corrected 1867 book text
default { "$To\public" }
}
Copy-Item $_.FullName $dest -Force
}
# the scripts use emoji/curly quotes: PowerShell 5.1 needs a UTF-8 BOM to read them right
Get-ChildItem "$To\tools\*.ps1", "$To\deploy.ps1" | ForEach-Object {
$t = [IO.File]::ReadAllText($_.FullName, [Text.Encoding]::UTF8); [IO.File]::WriteAllText($_.FullName, $t, (New-Object Text.UTF8Encoding $true))
}
# .sh files must have Linux line endings
Get-ChildItem "$To\proxmox\*.sh" | ForEach-Object { $t = [IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n"; [IO.File]::WriteAllText($_.FullName, $t) }
Get-ChildItem $To -Recurse -File | Select-Object @{ n = 'File'; e = { $_.FullName.Substring($To.Length + 1) } }, Length
EOF
2. Make the lesson file
PC (PowerShell). It prints "87 lessons" and one line per lesson, with any note:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-book.ps1 -Book spell1867
3. Record it, upload it, check it
PC (PowerShell):
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\make-audio.ps1 -Book spell1867
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-audio.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\check-audio.ps1 -Book spell1867
4. Put it on the server
PC (PowerShell):
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\deploy.ps1
Troubleshooting
- A line reads wrong: fix it in
C:\ABCBooks\source\spell1867.txt, then repeat steps 2 to 4 (only changed lines are recorded again). - A word's spelling is missing: run
check-audio.ps1 -Book spell1867; every word in the lines gets aspell:recording. - The logs and the Tablet check page are in the Primer post.
Notes
- The book's own last page is a spelling table (tear, fear, hear; soil, toil, boil...) and a page of the publisher's other books; neither is a lesson, so they're left out.
- The pictures are a guess from the words (a pig, a rat, a dog), otherwise a book π.