@happyvertical/smrt-education 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -1
- package/agents/class-crunchlabs.md +83 -0
- package/agents/course-bundle-import.md +108 -18
- package/dist/catalog-contracts.d.ts +6 -2
- package/dist/catalog-contracts.d.ts.map +1 -1
- package/dist/catalog-materialization-service.d.ts.map +1 -1
- package/dist/catalog-policy.d.ts +11 -1
- package/dist/catalog-policy.d.ts.map +1 -1
- package/dist/catalog-preview.d.ts +6 -1
- package/dist/catalog-preview.d.ts.map +1 -1
- package/dist/chunks/course-authoring-contracts-C4Hqba-e.js +131 -0
- package/dist/chunks/course-authoring-contracts-C4Hqba-e.js.map +1 -0
- package/dist/chunks/course-bundle-zip-5KUe3C5H.js +323 -0
- package/dist/chunks/course-bundle-zip-5KUe3C5H.js.map +1 -0
- package/dist/chunks/{workbook-photo-service-COtyQL1y.js → workbook-photo-service-CjcB0y8Y.js} +226 -130
- package/dist/chunks/workbook-photo-service-CjcB0y8Y.js.map +1 -0
- package/dist/course-authoring-contracts.d.ts +1 -1
- package/dist/course-authoring-contracts.d.ts.map +1 -1
- package/dist/course-bundle-contracts.d.ts +21 -3
- package/dist/course-bundle-contracts.d.ts.map +1 -1
- package/dist/course-bundle-manifest-util.d.ts +15 -0
- package/dist/course-bundle-manifest-util.d.ts.map +1 -0
- package/dist/course-bundle-manifest-v2.d.ts +66 -0
- package/dist/course-bundle-manifest-v2.d.ts.map +1 -0
- package/dist/course-bundle-models.d.ts +29 -1
- package/dist/course-bundle-models.d.ts.map +1 -1
- package/dist/course-bundle-service.d.ts +104 -5
- package/dist/course-bundle-service.d.ts.map +1 -1
- package/dist/course-bundle.d.ts +81 -2
- package/dist/course-bundle.d.ts.map +1 -1
- package/dist/document-converter.d.ts +30 -18
- package/dist/document-converter.d.ts.map +1 -1
- package/dist/hearth-catalog-providers.d.ts +1 -1
- package/dist/hearth-catalog-providers.d.ts.map +1 -1
- package/dist/hearth-class-crunchlabs.d.ts +94 -0
- package/dist/hearth-class-crunchlabs.d.ts.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1633 -399
- package/dist/index.js.map +1 -1
- package/dist/ingestion.js +1 -1
- package/dist/manifest.json +418 -6
- package/dist/node.d.ts +3 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +134 -0
- package/dist/node.js.map +1 -0
- package/dist/smrt-knowledge.json +162 -10
- package/dist/sources/class-crunchlabs-files.d.ts +8 -0
- package/dist/sources/class-crunchlabs-files.d.ts.map +1 -0
- package/dist/sources/class-crunchlabs.d.ts +217 -0
- package/dist/sources/class-crunchlabs.d.ts.map +1 -0
- package/package.json +12 -8
- package/dist/chunks/workbook-photo-service-COtyQL1y.js.map +0 -1
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-education",
|
|
6
|
-
"packageVersion": "0.
|
|
6
|
+
"packageVersion": "0.59.0",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
10
|
+
"manifest": "d6abefb071a665c56641e46b012dde1de313bfbb6076f81d384150211ac8a8db",
|
|
11
|
+
"packageJson": "f0aa70daa555aa2ad3935a7bc01a8ff3652d233d8c9ccf65fa4a2cdf89e53dc4",
|
|
12
|
+
"agents": "6f6c8fbb6bd5e2fa28c992987c889d7cc3b067566f1415863228908494a57ca3",
|
|
13
13
|
"moduleDoc:agents/lesson-materials.md": "f103812b0f5ebfda446747ae9e4d7a97254e22b12aebc2ec43a865c5bd56e56a",
|
|
14
14
|
"moduleDoc:agents/learner-service.md": "d7edf68000aec96b52920fb5e8dc4ff4e20b1d81b34413809e8212a885e1c3f9",
|
|
15
15
|
"moduleDoc:agents/submission-service.md": "0bb827a62848a3e0708fe2545eac7b64f829a6c9be96eee849c8f16c1fa631d0",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"moduleDoc:agents/course-generation-review.md": "7f6d5e67ed56ec9923bd00df05650b363fb0db30c1b1f004c8b203f9c62cd117",
|
|
21
21
|
"moduleDoc:agents/question-records.md": "c1648cccc27ed46b1b60cded93b20c7f3812cbcd2c37f183fd727ac030364703",
|
|
22
22
|
"moduleDoc:agents/question-extraction.md": "75972dda9b2f837471fb42f963ef143a098005c919a1a49db7ce395a5e5e50a7",
|
|
23
|
-
"moduleDoc:agents/course-bundle-import.md": "
|
|
23
|
+
"moduleDoc:agents/course-bundle-import.md": "9829211a6af7f600ea41a4143b4355b510bcb04c720d4b1011d3d98845c08377",
|
|
24
|
+
"moduleDoc:agents/class-crunchlabs.md": "3ce60bed13802d482fa7ba330363c6bed2c777eff803cdca6429330722ed63a8",
|
|
24
25
|
"moduleDoc:agents/source-import.md": "30ee823589d655442bcf1abcd86a2132172065b4bc5e3c4d89b1e8b7afcc66bc",
|
|
25
26
|
"moduleDoc:agents/family-intake.md": "540b6d37f438496acb0208b6ac7258264e2142bce3d2f986967b7741606fe4f7",
|
|
26
27
|
"moduleDoc:agents/catalog-previews.md": "5f4e0352274009b3ce00408017a16666036d24df695a7078c27317ba4e66f0a7",
|
|
@@ -32,7 +33,8 @@
|
|
|
32
33
|
".",
|
|
33
34
|
"./ingestion",
|
|
34
35
|
"./manifest",
|
|
35
|
-
"./manifest.json"
|
|
36
|
+
"./manifest.json",
|
|
37
|
+
"./node"
|
|
36
38
|
],
|
|
37
39
|
"dependencies": {
|
|
38
40
|
"@happyvertical/smrt-assets": "workspace:*",
|
|
@@ -5283,6 +5285,12 @@
|
|
|
5283
5285
|
"required": false,
|
|
5284
5286
|
"columnType": "TEXT"
|
|
5285
5287
|
},
|
|
5288
|
+
{
|
|
5289
|
+
"name": "audience",
|
|
5290
|
+
"type": "text",
|
|
5291
|
+
"required": false,
|
|
5292
|
+
"columnType": "TEXT"
|
|
5293
|
+
},
|
|
5286
5294
|
{
|
|
5287
5295
|
"name": "sectionKey",
|
|
5288
5296
|
"type": "text",
|
|
@@ -5394,6 +5402,145 @@
|
|
|
5394
5402
|
"tags": [],
|
|
5395
5403
|
"risks": []
|
|
5396
5404
|
},
|
|
5405
|
+
{
|
|
5406
|
+
"name": "CourseBundleRendering",
|
|
5407
|
+
"qualifiedName": "@happyvertical/smrt-education:CourseBundleRendering",
|
|
5408
|
+
"collection": "coursebundlerenderings",
|
|
5409
|
+
"description": "Append-only record that a Word/PowerPoint member whose first rendering failed was rendered later by a deliberate retry (SMRT #4615).",
|
|
5410
|
+
"tableName": "course_bundle_renderings",
|
|
5411
|
+
"packageName": "@happyvertical/smrt-education",
|
|
5412
|
+
"extends": "SmrtObject",
|
|
5413
|
+
"fields": [
|
|
5414
|
+
{
|
|
5415
|
+
"name": "tenantId",
|
|
5416
|
+
"type": "text",
|
|
5417
|
+
"required": true,
|
|
5418
|
+
"columnType": "UUID"
|
|
5419
|
+
},
|
|
5420
|
+
{
|
|
5421
|
+
"name": "importId",
|
|
5422
|
+
"type": "foreignKey",
|
|
5423
|
+
"required": true,
|
|
5424
|
+
"related": "CourseBundleImport",
|
|
5425
|
+
"columnType": "UUID"
|
|
5426
|
+
},
|
|
5427
|
+
{
|
|
5428
|
+
"name": "sha256",
|
|
5429
|
+
"type": "text",
|
|
5430
|
+
"required": false,
|
|
5431
|
+
"columnType": "TEXT"
|
|
5432
|
+
},
|
|
5433
|
+
{
|
|
5434
|
+
"name": "renderedSha256",
|
|
5435
|
+
"type": "text",
|
|
5436
|
+
"required": false,
|
|
5437
|
+
"columnType": "TEXT"
|
|
5438
|
+
},
|
|
5439
|
+
{
|
|
5440
|
+
"name": "renderedSizeBytes",
|
|
5441
|
+
"type": "integer",
|
|
5442
|
+
"required": true,
|
|
5443
|
+
"columnType": "INTEGER",
|
|
5444
|
+
"default": 0
|
|
5445
|
+
},
|
|
5446
|
+
{
|
|
5447
|
+
"name": "converterId",
|
|
5448
|
+
"type": "text",
|
|
5449
|
+
"required": false,
|
|
5450
|
+
"columnType": "TEXT"
|
|
5451
|
+
},
|
|
5452
|
+
{
|
|
5453
|
+
"name": "retriedByUserId",
|
|
5454
|
+
"type": "text",
|
|
5455
|
+
"required": false,
|
|
5456
|
+
"columnType": "TEXT"
|
|
5457
|
+
},
|
|
5458
|
+
{
|
|
5459
|
+
"name": "recordedAt",
|
|
5460
|
+
"type": "text",
|
|
5461
|
+
"required": false,
|
|
5462
|
+
"columnType": "TEXT"
|
|
5463
|
+
}
|
|
5464
|
+
],
|
|
5465
|
+
"relationships": [
|
|
5466
|
+
{
|
|
5467
|
+
"name": "importId",
|
|
5468
|
+
"type": "foreignKey",
|
|
5469
|
+
"required": true,
|
|
5470
|
+
"related": "CourseBundleImport",
|
|
5471
|
+
"columnType": "UUID"
|
|
5472
|
+
}
|
|
5473
|
+
],
|
|
5474
|
+
"methods": [
|
|
5475
|
+
"delete",
|
|
5476
|
+
"save"
|
|
5477
|
+
],
|
|
5478
|
+
"methodSignatures": [
|
|
5479
|
+
{
|
|
5480
|
+
"name": "delete",
|
|
5481
|
+
"async": true,
|
|
5482
|
+
"returns": "Promise<void>"
|
|
5483
|
+
},
|
|
5484
|
+
{
|
|
5485
|
+
"name": "save",
|
|
5486
|
+
"async": true,
|
|
5487
|
+
"params": [
|
|
5488
|
+
"options?: SmrtSaveOptions"
|
|
5489
|
+
],
|
|
5490
|
+
"returns": "Promise<this>"
|
|
5491
|
+
}
|
|
5492
|
+
],
|
|
5493
|
+
"tenant": {
|
|
5494
|
+
"scoped": true,
|
|
5495
|
+
"mode": "required",
|
|
5496
|
+
"field": "tenantId"
|
|
5497
|
+
},
|
|
5498
|
+
"conflictColumns": [
|
|
5499
|
+
"tenant_id",
|
|
5500
|
+
"import_id",
|
|
5501
|
+
"sha256"
|
|
5502
|
+
],
|
|
5503
|
+
"surfaces": [],
|
|
5504
|
+
"withheldSurfaces": [
|
|
5505
|
+
{
|
|
5506
|
+
"kind": "api",
|
|
5507
|
+
"operation": "delete",
|
|
5508
|
+
"code": "api-disabled",
|
|
5509
|
+
"reason": "api is disabled",
|
|
5510
|
+
"objectName": "@happyvertical/smrt-education:CourseBundleRendering"
|
|
5511
|
+
},
|
|
5512
|
+
{
|
|
5513
|
+
"kind": "api",
|
|
5514
|
+
"operation": "save",
|
|
5515
|
+
"code": "api-disabled",
|
|
5516
|
+
"reason": "api is disabled",
|
|
5517
|
+
"objectName": "@happyvertical/smrt-education:CourseBundleRendering"
|
|
5518
|
+
}
|
|
5519
|
+
],
|
|
5520
|
+
"relationshipFeatures": [
|
|
5521
|
+
"foreignKey",
|
|
5522
|
+
"uuidColumns"
|
|
5523
|
+
],
|
|
5524
|
+
"tags": [],
|
|
5525
|
+
"risks": []
|
|
5526
|
+
},
|
|
5527
|
+
{
|
|
5528
|
+
"name": "CourseBundleRenderingCollection",
|
|
5529
|
+
"qualifiedName": "@happyvertical/smrt-education:CourseBundleRenderingCollection",
|
|
5530
|
+
"collection": "coursebundlerenderings",
|
|
5531
|
+
"tableName": "course_bundle_renderings",
|
|
5532
|
+
"packageName": "@happyvertical/smrt-education",
|
|
5533
|
+
"extends": "SmrtCollection",
|
|
5534
|
+
"fields": [],
|
|
5535
|
+
"relationships": [],
|
|
5536
|
+
"methods": [],
|
|
5537
|
+
"surfaces": [],
|
|
5538
|
+
"relationshipFeatures": [
|
|
5539
|
+
"uuidColumns"
|
|
5540
|
+
],
|
|
5541
|
+
"tags": [],
|
|
5542
|
+
"risks": []
|
|
5543
|
+
},
|
|
5397
5544
|
{
|
|
5398
5545
|
"name": "CourseGenerationJob",
|
|
5399
5546
|
"qualifiedName": "@happyvertical/smrt-education:CourseGenerationJob",
|
|
@@ -15484,14 +15631,14 @@
|
|
|
15484
15631
|
"surfaces": [],
|
|
15485
15632
|
"prompts": [],
|
|
15486
15633
|
"relationshipsV2": {
|
|
15487
|
-
"foreignKeyFields":
|
|
15634
|
+
"foreignKeyFields": 189,
|
|
15488
15635
|
"crossPackageRefFields": 24,
|
|
15489
15636
|
"junctionCollections": 2,
|
|
15490
15637
|
"hierarchicalObjects": 0,
|
|
15491
15638
|
"polymorphicAssociations": 0,
|
|
15492
|
-
"uuidColumns":
|
|
15639
|
+
"uuidColumns": 800
|
|
15493
15640
|
},
|
|
15494
|
-
"agentDoc": "# smrt-education\n\nCatalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,\nor AI surface. Applications use `CatalogService`, supplying their actor-bound\nauthorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`\nadapter from smrt-users. Catalog authorization receives the exact shared collection\nexecutor; live session/permission checks must use that executor. That read scope may widen only an allowlisted catalog\ncollection's `list` to self, ancestors, and globals; it never authorizes a get,\nwrite, raw query, sibling, or descendant read.\n\n`personal-use-copy` courses and all `answer-key`/`assessment-guidance` material\nare private family material. Do not publish them, promote them, or expose them\nthrough a learner-facing result. `LessonAsset` (lesson) and `CourseAsset` (course)\nare the sole owner joins for material files; do not add asset-id fields to lesson\nsubclasses. Material audience rules and external videos: [lesson materials](agents/lesson-materials.md);\na learner never sees guardian-audience material.\n\nLearner objects require exact tenant ownership and close generated surfaces.\n`LearnerService` owns actor/resource authorization and transactional planning and\nstart-progress; see [learner contract](agents/learner-service.md). Guardian\nrelationships require a host decision about their type and effective terms.\nNever expose direct collection writes as learner handlers, reset progress on a\nretry, or treat an unscored nullable decimal as zero.\n\nSubmission capture, guardian grading and private PDF packet splitting use\n`SubmissionService`; see [submission contract](agents/submission-service.md).\nKeep asset metadata on the same transaction and compensate only newly planned\nfiles after rollback; never expose source URIs or accept learner grade patches.\n\nValidation: `pnpm --filter @happyvertical/smrt-education test`,\n`test:postgres`, `typecheck`, and `build`.\n\nEvery lesson hand-in is lesson work (activity, attempt, revisions, release,\ncompletion decision); books are courses with `bookKey`, pages are `LessonPage`s,\nand loose work is placed by filing. Use [LessonWorkService](agents/lesson-work-service.md)\nand the [design record](agents/course-work-design.md); `migrateWorkbooksToCourses`\nis the one-time host migration. Keep provisional sharing separate from guardian\nGrade authority; never move filed work's IDs. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\nRevisioned authored courses, release, archive, enrollment pins and licence use\n[CourseAuthoringService](agents/course-authoring-service.md). Keep history\nappend-only, require request keys, and never weaken personal-use-copy rules.\n\nGeneration proposals into new draft revisions and revision-bound educational\nreview use [CourseGenerationReviewService](agents/course-generation-review.md).\nKeep supplied content unless a decision is recorded; reviews carry no score.\n\nImmutable questions, answer keys/rubrics, stable learning objectives, worksheet\nrender ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).\nKeep keys out of learner bundles; a missing key stays null. `attachGeneratedPrintable`\nstores the rendered PDFs; the guardian copy is always an `answer-key`, never `printable`.\n\nStructured questions from scans and imported course files use\n[QuestionExtractionService](agents/question-extraction.md) and the `extract-questions`\ningestion handler. Anchors come from provider region catalogs only; match by worksheet\nid and layout, never text; ambiguous matches stay unattached; source-keyed replays.\n\nCourse bundles (zip with a closed manifest, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed) use\n[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the\nbounded reader; refuse, never trim, hostile input; unsupported members stay\nvisible; proposals only, nothing auto-published.\n\nSource import: [host boundary and transaction contract](agents/source-import.md).\n`SourceImportService` requires host opt-in, matching active actor/tenant and\nexplicit licensing authorization; never add a system/global write bypass.\n\nFamily-owned unassigned originals and audited learner adoption use\n[FamilyIntakeService](agents/family-intake.md). Keep family source scope immutable;\nassignment alone never creates learning history or grants learner inbox access.\n\nPublisher catalog previews use [the preview contract](agents/catalog-previews.md).\nKeep independent image rights and source revisions; serve via `CatalogPreviewService`\nwith live actor/tenant authorization, never direct provider image URLs.\n\nEnrollment Plan projections and guardian-only parent notes use\n[the Plan contract](agents/learner-plan.md). Keep awaiting review separate from\nContinue; note authority/provenance never grants learner access or learning evidence.\n\nHomeschool group sharing uses [GroupSharingService](agents/group-sharing.md),\nits read-only reader and revision comment service. Group tenants never become\nfamily ancestors or guardians. Enumerate only the group-owned opaque roster index,\nthen revalidate each exact family edge and current policy. Media delivery must call\nthe reader on every request; signed storage URLs are not a sharing grant. Saved\nparent comment visibility applies on every child read. Progress with feedback\ndisabled contains counts only, never correctness, scores or concept labels.\n\nLearner-safe tutor context and append-only tutor transcripts use\n[TutorContextService and TutorSessionService](agents/tutor-context.md). Learner\naudience never carries keys, rubrics, notes or storage URIs; no AI call here.\n",
|
|
15641
|
+
"agentDoc": "# smrt-education\n\nCatalog objects are optional-tenant scoped and have no generated REST, CLI, MCP,\nor AI surface. Applications use `CatalogService`, supplying their actor-bound\nauthorization decision and the trusted `TenantCollection.withTenantCatalogReadScope`\nadapter from smrt-users. Catalog authorization receives the exact shared collection\nexecutor; live session/permission checks must use that executor. That read scope may widen only an allowlisted catalog\ncollection's `list` to self, ancestors, and globals; it never authorizes a get,\nwrite, raw query, sibling, or descendant read.\n\n`personal-use-copy` courses and all `answer-key`/`assessment-guidance` material\nare private family material. Do not publish them, promote them, or expose them\nthrough a learner-facing result. `LessonAsset` (lesson) and `CourseAsset` (course)\nare the sole owner joins for material files; do not add asset-id fields to lesson\nsubclasses. Material audience rules and external videos: [lesson materials](agents/lesson-materials.md);\na learner never sees guardian-audience material.\n\nLearner objects require exact tenant ownership and close generated surfaces.\n`LearnerService` owns actor/resource authorization and transactional planning and\nstart-progress; see [learner contract](agents/learner-service.md). Guardian\nrelationships require a host decision about their type and effective terms.\nNever expose direct collection writes as learner handlers, reset progress on a\nretry, or treat an unscored nullable decimal as zero.\n\nSubmission capture, guardian grading and private PDF packet splitting use\n`SubmissionService`; see [submission contract](agents/submission-service.md).\nKeep asset metadata on the same transaction and compensate only newly planned\nfiles after rollback; never expose source URIs or accept learner grade patches.\n\nValidation: `pnpm --filter @happyvertical/smrt-education test`,\n`test:postgres`, `typecheck`, and `build`.\n\nEvery lesson hand-in is lesson work (activity, attempt, revisions, release,\ncompletion decision); books are courses with `bookKey`, pages are `LessonPage`s,\nand loose work is placed by filing. Use [LessonWorkService](agents/lesson-work-service.md)\nand the [design record](agents/course-work-design.md); `migrateWorkbooksToCourses`\nis the one-time host migration. Keep provisional sharing separate from guardian\nGrade authority; never move filed work's IDs. Photo-first receipt/context\ncapture uses [WorkbookPhotoService](agents/photo-intake.md); covers/references\nnever create learning evidence, and blank exercises need explicit work intent.\n\nRevisioned authored courses, release, archive, enrollment pins and licence use\n[CourseAuthoringService](agents/course-authoring-service.md). Keep history\nappend-only, require request keys, and never weaken personal-use-copy rules.\n\nGeneration proposals into new draft revisions and revision-bound educational\nreview use [CourseGenerationReviewService](agents/course-generation-review.md).\nKeep supplied content unless a decision is recorded; reviews carry no score.\n\nImmutable questions, answer keys/rubrics, stable learning objectives, worksheet\nrender ids and exact-revision references use [QuestionAuthoringService](agents/question-records.md).\nKeep keys out of learner bundles; a missing key stays null. `attachGeneratedPrintable`\nstores the rendered PDFs; the guardian copy is always an `answer-key`, never `printable`.\n\nStructured questions from scans and imported course files use\n[QuestionExtractionService](agents/question-extraction.md) and the `extract-questions`\ningestion handler. Anchors come from provider region catalogs only; match by worksheet\nid and layout, never text; ambiguous matches stay unattached; source-keyed replays.\n\nCourse bundles (zip with a closed manifest v1 or v2, or a file set; Word/PowerPoint members render through an injected `DocumentConverter`, fail closed; v2 carries material roles, audience, lesson pages, course members and YouTube references) use\n[CourseBundleImportService](agents/course-bundle-import.md). Parse only with the\nbounded reader; refuse, never trim, hostile input; unsupported members stay\nvisible; proposals only, nothing auto-published.\n\nClass CrunchLabs (a download read into a manifest v2 bundle; an operator-only\nglobal catalog provider until the operator's single public switch):\n[source contract](agents/class-crunchlabs.md). Never host its files; `operator`\nvisibility needs the host's `operatorTenantIds`.\n\nSource import: [host boundary and transaction contract](agents/source-import.md).\n`SourceImportService` requires host opt-in, matching active actor/tenant and\nexplicit licensing authorization; never add a system/global write bypass.\n\nFamily-owned unassigned originals and audited learner adoption use\n[FamilyIntakeService](agents/family-intake.md). Keep family source scope immutable;\nassignment alone never creates learning history or grants learner inbox access.\n\nPublisher catalog previews use [the preview contract](agents/catalog-previews.md).\nKeep independent image rights and source revisions; serve via `CatalogPreviewService`\nwith live actor/tenant authorization, never direct provider image URLs.\n\nEnrollment Plan projections and guardian-only parent notes use\n[the Plan contract](agents/learner-plan.md). Keep awaiting review separate from\nContinue; note authority/provenance never grants learner access or learning evidence.\n\nHomeschool group sharing uses [GroupSharingService](agents/group-sharing.md),\nits read-only reader and revision comment service. Group tenants never become\nfamily ancestors or guardians. Enumerate only the group-owned opaque roster index,\nthen revalidate each exact family edge and current policy. Media delivery must call\nthe reader on every request; signed storage URLs are not a sharing grant. Saved\nparent comment visibility applies on every child read. Progress with feedback\ndisabled contains counts only, never correctness, scores or concept labels.\n\nLearner-safe tutor context and append-only tutor transcripts use\n[TutorContextService and TutorSessionService](agents/tutor-context.md). Learner\naudience never carries keys, rubrics, notes or storage URIs; no AI call here.\n",
|
|
15495
15642
|
"moduleDocs": [
|
|
15496
15643
|
{
|
|
15497
15644
|
"path": "agents/lesson-materials.md",
|
|
@@ -15546,7 +15693,12 @@
|
|
|
15546
15693
|
{
|
|
15547
15694
|
"path": "agents/course-bundle-import.md",
|
|
15548
15695
|
"module": "course-bundle-import",
|
|
15549
|
-
"content": "# Course bundle import\n\n`CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,\nconsumed by Hearth #94) imports a course bundle, a zip with an optional manifest\nor a plain file set, as bounded evidence, hands its supported members to the\nhost for ingestion extraction and proposes sections and lessons for review.\nRisk is high: untrusted archive parsing and new schema. It reuses the course\nauthorizer: `importBundle` needs `education.course.author`, `readImport`\n`education.course.read-draft`, `proposeStructure` `education.course.generate`\n(via `CourseGenerationReviewService`). Operations: `import-course-bundle`,\n`read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.\n\n## Flow\n\n1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`\n authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays\n out the structure (`classify`), writes the evidence in one transaction and\n then calls `receiveMember` for each accepted member (`extract`), outside any\n transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,\n bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`\n (`reason`, optional `entry`) and writes nothing.\n2. `receiveMember(member, { replayed })` is the host's ingestion seam: call\n `IngestionService.receive` with `sourceKind: 'course_import'` and the member\n bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in\n [question extraction](question-extraction.md). Members carry `role`\n (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too\n (ingestion dedups by content hash), so an interrupted first run resumes. Its\n return values are in `result.receipts`.\n3. `proposeStructure({ ...actor, importId, bundle, courseId,\n expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes\n are never retained), checks it is the imported one, and runs a deterministic\n `CourseMaterialGenerator` (`smrt-bundle-import`) through\n `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a\n new draft revision of additions and fills; conflicts wait for\n `decideProposal`. Nothing is released, assigned or published. The request key\n is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the\n same key with another head is `CourseRequestKeyConflictError`.\n4. `readImport({ ...actor, importId })` returns the recorded evidence.\n\nProgress is an observer callback (`onProgress({ stage, completed, total })`,\nstages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;\n`render` is emitted only when the bundle has Word/PowerPoint members).\nIngestion has no public job-progress seam, so none is invented. A throwing\nlistener never affects the import.\n\n## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)\n\n| limit | value |\n|---|---|\n| `maxArchiveBytes` (input) | 100 MiB |\n| `maxEntries` | 200 |\n| `maxEntryBytes` (inflated) | 50 MiB |\n| `maxTotalBytes` (inflated) | 200 MiB |\n| `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |\n| `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |\n| `maxManifestBytes` | 256 KiB |\n| `maxLessons` (the proposal schema's cap) | 50 |\n| `maxLessonBodyChars` | 100 000 |\n| `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |\n| `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |\n| `conversionTimeoutMs` (one Office member) | 60 000 |\n| archive nesting | 1: a zip inside a zip is never expanded |\n\nA lesson whose combined text members exceed `maxLessonBodyChars` refuses the\nbundle (`lesson_too_large`, `entry` = lesson key) at import; text is never\ntrimmed or dropped. A plain file set enforces `maxTotalBytes` on its member\nbytes exactly as an archive does on inflated bytes.\n\nThe reader (`course-bundle-zip.ts`) parses the end record and central directory,\nvalidates every offset and size against the real buffer, inflates with\n`maxOutputLength` equal to the declared size (declared sizes are never trusted:\na mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total\non actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)\ncover: not a zip, truncated, ambiguous end record (two end records that both fit\nthe file, for example one forged in a comment), zip64, multi-disk, central\ndirectory not ending at the end record, entries out of range or overlapping,\nlocal header name/method mismatch, encrypted entries (flags 0/6/13, AES method),\nsymlink and special files (unix mode; DOS reparse flag), unsupported method,\nbad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,\nbackslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,\ndrive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,\ntoo long or deep). An **honestly** oversize member (declared over\n`maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.\n\n## Members and statuses\n\nBy content (never by extension alone): `docx`/`pptx` (see below), `pdf` (`%PDF-` in the first 1 KiB),\n`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control\ncharacters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.\nStatus: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier\nmember in canonical order; recorded once as evidence, `duplicateOfPath`, never\nextracted again), `unsupported` (`unsupported_type`, `nested_archive`,\n`unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`\n(the manifest). Nothing is dropped. An encrypted PDF is any PDF with an\n`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive\non literal text inside an uncompressed stream is refused, never read.\n\n## Word and PowerPoint members (SMRT #4615)\n\nA `.docx`/`.pptx` member is an OOXML package only when the name and the content\nagree: a zip whose entries include `[Content_Types].xml` and `word/document.xml`\n(docx) or `ppt/presentation.xml` (pptx). Before the bytes go anywhere, the\npackage itself passes the same bounded zip reader (ratio, overlap, paths, CRC,\n100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the\nwhole bundle. Other zips stay `unsupported / nested_archive`.\n\nTwo separate things happen to an accepted Office member, both after\nauthorization and before the evidence is written:\n\n- **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original\n to PDF. The importer enforces the byte and time limits itself (input\n `maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort\n signal and a hard race, so an adapter that ignores the signal still times out),\n and rejects output that is not a PDF or is encrypted. The converter is told a\n neutral file name (`document.docx`), never the bundle path. Identical documents\n are rendered once.\n- **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`\n costs the member nothing) returns whole-document text and, for decks, text and\n speaker notes per slide. Office text is handed to the host on the accepted\n member (`officeText`) and is **never** inlined into a proposed lesson body:\n teacher guides and notes carry answers, and audience belongs to manifest v2.\n\n`receiveMember` gets the original `bytes` (store them as the private original\nasset: \"Open in PowerPoint\" for decks) and `rendered` (`{ bytes, sha256, mediaType:\n'application/pdf', converterId }`, store as a private asset beside the original:\nthe pages for \"Present\"/reading). The member row records `renderedSha256` and\n`renderedSizeBytes`; bytes are still not retained here.\n\n**Fail closed.** Any converter problem records that member `unsupported` with a\nreason and writes the rest of the bundle normally: `converter_unavailable` (none\nconfigured), `converter_failed` (unreachable or 5xx), `converter_timeout`,\n`converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),\n`office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that\nfailed is not a lesson in the proposal, and identical copies of a failed document\nfail the same way rather than becoming duplicates of nothing. Outcomes are part\nof the append-only evidence, so a replay does not retry a recorded failure (to\nretry, change the bundle). A replay does re-render recorded-accepted members so the\nhost receives the bytes again; if it can no longer render one it throws\n`DocumentConversionError` rather than silently dropping an accepted member.\n\n### Host configuration\n\n```ts\nconst service = new CourseBundleImportService(db, authorizer, {\n converter: new GotenbergDocumentConverter({\n baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000\n timeoutMs: 60_000,\n maxBytes: 50 * 1024 * 1024,\n }),\n officeTextReader, // optional, see below\n});\n```\n\n`GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks\nto nothing else, refuses redirects and URLs with credentials, and caps the response\nas it streams. Run Gotenberg as an internal-only service with **no outbound\nnetwork**, its own size/time limits and no shared volume with the app; this\npackage assumes untrusted files never run next to the application. Tests use\n`FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).\n\n`OfficeTextReader` is deliberately not wired to the SDK here, so this package\ndoes not depend on an unreleased `@happyvertical/documents`. Once the Office\nprocessors ship (sdk#1485, `extractOffice(bytes, 'docx' | 'pptx')`), implement\n`read` with it; the snippet is in the `OfficeTextReader` doc comment.\n\n## Manifest (`manifest.json`, version 1, closed)\n\n```json\n{ \"version\": 1, \"course\": { \"subject\": \"Math\" },\n \"sections\": [ { \"key\": \"unit-1\", \"title\": \"Unit One\",\n \"lessons\": [ { \"key\": \"sums\", \"title\": \"Sums\",\n \"members\": [ { \"path\": \"sheets/sums.pdf\", \"role\": \"exercise\" } ] } ] } ] }\n```\nEvery object is closed: an unknown key anywhere is `manifest_unknown_key` (with\nits path in `entry`); another version is `manifest_version`. Keys match\n`[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles\n(`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,\n`unknown`); a listed path must exist and be listed once. The manifest is a source\nfact: roles are `source`, anything the extractor infers stays `inferred`, and a\nmanifest/extraction disagreement belongs in extraction review, not here. Without\na manifest each accepted member is one lesson, sections come from the top-level\ndirectory and order is natural path order (digit runs compare numerically, no\nlocale). Manifest order: the manifest, declared members, then the rest by path.\nLesson bodies are the readable text members of a lesson, joined; PDFs and images\nare never inlined. Members whose declared role is `answer_key` or `mixed` never\ncontribute text to a lesson body (they remain recorded evidence members); the\nproposal rationale only counts how many were withheld.\n\n## Readers\n\n`BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;\nbuilt-ins are `manifest-v1` and `plain-files`. SCORM and Common Cartridge are\n**not implemented** (their manifests describe launch resources and activity\ntrees that do not map to sections/lessons without design); a host can register\na reader ahead of the built-ins (`register(reader, { first: true })`).\n\n## Identity and replay\n\n`bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so\nre-zipping, reordering and timestamps replay. One `CourseBundleImport` per\n(tenant, identity); a concurrent first import replays once on the unique index.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseBundleImport` (`course_bundle_imports`: identity, format, reader,\nmanifest version, counts, actor) and `CourseBundleMember` (`course_bundle_members`:\nunique tenant/import/path; ordinal, sha256, size, media type, kind, status,\nreason, duplicate-of, role, section and lesson key). Hashes and placements only:\nbytes stay with the host's ingestion receipts. Rewrites and deletes throw.\nNormal manifest migrations create both tables.\n\n## Not in this release\n\n- Manifest version 2 (material roles with audience, lesson pages, course-level\n members, external video references) waits for the phase 1 role vocabulary\n (smrt#4614). The page-role vocabulary and the closed v1 schema are unchanged;\n see the hand-off note on smrt#4615.\n- Re-rendering a member whose first import recorded a converter failure (outcomes\n are append-only per bundle identity).\n- Ingestion's receipt contract is unchanged: there is no `application/zip` part\n type or `parentEvidenceId` in ingestion. The education side unpacks and the\n host receives members one by one, so archive-to-member lineage lives in\n `course_bundle_members` (and the host's receipts), not in ingestion evidence.\n Changing ingestion's published receipt is a separate design decision.\n- A manifest/extraction disagreement is not compared automatically.\n- SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.\n"
|
|
15696
|
+
"content": "# Course bundle import\n\n`CourseBundleImportService` (SMRT #4578, extends `CourseAuthoringService`,\nconsumed by Hearth #94) imports a course bundle, a zip with an optional manifest\nor a plain file set, as bounded evidence, hands its supported members to the\nhost for ingestion extraction and proposes sections and lessons for review.\nRisk is high: untrusted archive parsing and new schema. It reuses the course\nauthorizer: `importBundle` needs `education.course.author`, `readImport`\n`education.course.read-draft`, `proposeStructure` `education.course.generate`\n(via `CourseGenerationReviewService`). Operations: `import-course-bundle`,\n`read-course-bundle`. Server only (Node `zlib`/`crypto`); no new dependency.\n\n## Flow\n\n1. `importBundle({ ...actor, bundle, limits?, onProgress?, receiveMember? })`\n authorizes **before parsing**, unpacks (stage `unpack`), classifies and lays\n out the structure (`classify`), writes the evidence in one transaction and\n then calls `receiveMember` for each accepted member (`extract`), outside any\n transaction. `bundle` is `{ archive: Uint8Array }` or `{ files: [{ path,\n bytes }] }`. Hostile or damaged input throws `CourseBundleRefusedError`\n (`reason`, optional `entry`) and writes nothing.\n2. `receiveMember(member, { replayed })` is the host's ingestion seam: call\n `IngestionService.receive` with `sourceKind: 'course_import'` and the member\n bytes, then queue extraction and `preserveQuestionExtractionAnalysis` as in\n [question extraction](question-extraction.md). Members carry `role`\n (manifest page role), `lessonKey`, `sectionKey`. It runs on a replay too\n (ingestion dedups by content hash), so an interrupted first run resumes. Its\n return values are in `result.receipts`.\n3. `proposeStructure({ ...actor, importId, bundle, courseId,\n expectedHeadRevisionId })` (stage `propose`) re-supplies the bundle (bytes\n are never retained), checks it is the imported one, and runs a deterministic\n `CourseMaterialGenerator` (`smrt-bundle-import`) through\n `requestGeneration`/`runGeneration`. The result is an ordinary proposal: a\n new draft revision of additions and fills; conflicts wait for\n `decideProposal`. Nothing is released, assigned or published. The request key\n is `bundle:<courseId>:<bundleIdentity>`, so a retry returns the same job; the\n same key with another head is `CourseRequestKeyConflictError`.\n4. `readImport({ ...actor, importId })` returns the recorded evidence.\n\nProgress is an observer callback (`onProgress({ stage, completed, total })`,\nstages `unpack`, `classify`, `render`, `extract`, `propose`; each starts at `completed: 0`;\n`render` is emitted only when the bundle has Word/PowerPoint members).\nIngestion has no public job-progress seam, so none is invented. A throwing\nlistener never affects the import.\n\n## Hard bounds (`COURSE_BUNDLE_LIMITS`; a host may only lower them)\n\n| limit | value |\n|---|---|\n| `maxArchiveBytes` (input) | 100 MiB |\n| `maxEntries` | 200 |\n| `maxEntryBytes` (inflated) | 50 MiB |\n| `maxTotalBytes` (inflated) | 200 MiB |\n| `maxCompressionRatio` (members over `ratioFloorBytes` = 64 KiB) | 100 |\n| `maxPathBytes` / `maxSegmentBytes` / `maxPathDepth` | 512 / 255 / 8 |\n| `maxManifestBytes` | 256 KiB |\n| `maxLessons` (the proposal schema's cap) | 50 |\n| `maxLessonBodyChars` | 100 000 |\n| `maxOfficeMembers` (Word/PowerPoint members rendered per bundle) | 50 |\n| `maxRenderedBytes` (PDF accepted back from the converter) | 50 MiB |\n| `conversionTimeoutMs` (one Office member) | 60 000 |\n| archive nesting | 1: a zip inside a zip is never expanded |\n\nA lesson whose combined text members exceed `maxLessonBodyChars` refuses the\nbundle (`lesson_too_large`, `entry` = lesson key) at import; text is never\ntrimmed or dropped. A plain file set enforces `maxTotalBytes` on its member\nbytes exactly as an archive does on inflated bytes.\n\nThe reader (`course-bundle-zip.ts`) parses the end record and central directory,\nvalidates every offset and size against the real buffer, inflates with\n`maxOutputLength` equal to the declared size (declared sizes are never trusted:\na mismatch either way is `size_mismatch`), checks CRC32 and re-checks the total\non actual output. Stored and deflate only. Refusals (`CourseBundleRefusalReason`)\ncover: not a zip, truncated, ambiguous end record (two end records that both fit\nthe file, for example one forged in a comment), zip64, multi-disk, central\ndirectory not ending at the end record, entries out of range or overlapping,\nlocal header name/method mismatch, encrypted entries (flags 0/6/13, AES method),\nsymlink and special files (unix mode; DOS reparse flag), unsupported method,\nbad CRC, ratio, entry count, totals, duplicate names (case/NFC-insensitive,\nbackslashes normalised first) and bad paths (`..`, `.`, empty segments, absolute,\ndrive letter or `:` anywhere, NUL/control/bidi characters, lone surrogates,\ntoo long or deep). An **honestly** oversize member (declared over\n`maxEntryBytes`) is not inflated and is recorded `refused / entry_too_large`.\n\n## Members and statuses\n\nBy content (never by extension alone): `docx`/`pptx`/`xlsx` (see below), `pdf` (`%PDF-` in the first 1 KiB),\n`image` (PNG, JPEG, WebP), `text` (`.txt`/`.md`, strict UTF-8, no control\ncharacters, body-sized), `manifest` (root `manifest.json`), else `unsupported`.\nStatus: `accepted` (handed to extraction), `duplicate` (same sha256 as an earlier\nmember in canonical order; recorded once as evidence, `duplicateOfPath`, never\nextracted again), `unsupported` (`unsupported_type`, `nested_archive`,\n`unreadable_text`), `refused` (`encrypted_pdf`, `entry_too_large`), `metadata`\n(the manifest). Nothing is dropped. An encrypted PDF is any PDF with an\n`/Encrypt` token anywhere in its bytes (trailer, xref stream): a false positive\non literal text inside an uncompressed stream is refused, never read.\n\n## Word and PowerPoint members (SMRT #4615)\n\nA `.docx`/`.pptx`/`.xlsx` member is an OOXML package only when the name and the\ncontent agree: a zip whose entries include `[Content_Types].xml` and\n`word/document.xml` (docx), `ppt/presentation.xml` (pptx) or `xl/workbook.xml`\n(xlsx). A workbook (a supply list) is rendered and kept like the others but has\nno text reader. Before the bytes go anywhere, the\npackage itself passes the same bounded zip reader (ratio, overlap, paths, CRC,\n100 MiB inflated); a failure is `refused / unsafe_office`, never a refusal of the\nwhole bundle. Other zips stay `unsupported / nested_archive`.\n\nTwo separate things happen to an accepted Office member, both after\nauthorization and before the evidence is written:\n\n- **Pages**: a `DocumentConverter` (`document-converter.ts`) renders the original\n to PDF. The importer enforces the byte and time limits itself (input\n `maxEntryBytes`, output `maxRenderedBytes`, `conversionTimeoutMs` with an abort\n signal and a hard race, so an adapter that ignores the signal still times out),\n and rejects output that is not a PDF or is encrypted. The converter is told a\n neutral file name (`document.docx`), never the bundle path. Identical documents\n are rendered once.\n- **Text**: an injectable `OfficeTextReader` (best effort; a failure or a `null`\n costs the member nothing) returns whole-document text and, for decks, text and\n speaker notes per slide. Office text is handed to the host on the accepted\n member (`officeText`) and is **never** inlined into a proposed lesson body:\n teacher guides and notes carry answers, and audience belongs to manifest v2.\n\n`receiveMember` gets the original `bytes` (store them as the private original\nasset: \"Open in PowerPoint\" for decks) and `rendered` (`{ bytes, sha256, mediaType:\n'application/pdf', converterId }`, store as a private asset beside the original:\nthe pages for \"Present\"/reading). The member row records `renderedSha256` and\n`renderedSizeBytes`; bytes are still not retained here.\n\n**Fail closed.** Any converter problem records that member `unsupported` with a\nreason and writes the rest of the bundle normally: `converter_unavailable` (none\nconfigured), `converter_failed` (unreachable or 5xx), `converter_timeout`,\n`converter_too_large`, `converter_rejected` (HTTP 4xx, not a PDF, encrypted),\n`office_limit` (beyond `maxOfficeMembers`, in natural path order). A member that\nfailed is not a lesson in the proposal, and identical copies of a failed document\nfail the same way rather than becoming duplicates of nothing. Outcomes are part\nof the append-only evidence, so a replay does not retry a recorded failure (to\nretry, change the bundle). A replay does re-render recorded-accepted members so the\nhost receives the bytes again; if it can no longer render one it throws\n`DocumentConversionError` rather than silently dropping an accepted member.\n\n### Host configuration\n\n```ts\nconst service = new CourseBundleImportService(db, authorizer, {\n converter: new GotenbergDocumentConverter({\n baseUrl: process.env.GOTENBERG_URL, // for example http://gotenberg:3000\n timeoutMs: 60_000,\n maxBytes: 50 * 1024 * 1024,\n }),\n officeTextReader, // optional, see below\n});\n```\n\n`GotenbergDocumentConverter` posts to `<baseUrl>/forms/libreoffice/convert`, talks\nto nothing else, refuses redirects and URLs with credentials, and caps the response\nas it streams. Run Gotenberg as an internal-only service with **no outbound\nnetwork**, its own size/time limits and no shared volume with the app; this\npackage assumes untrusted files never run next to the application. Tests use\n`FakeDocumentConverter` (records calls; `failWith`, `failWhen`, `delayMs`, `output`).\n\n`OfficeTextReader` reads Office text through the SDK without this package\ndepending on `@happyvertical/documents`: the host imports `extractOffice`\n(released in `@happyvertical/documents` 0.111.4, sdk#1486, Kreuzberg) and passes\nit to `createOfficeTextReader(extractOffice)`, which returns whole-document\ntext for Word and text plus speaker notes per slide for PowerPoint (images are\nnever requested). An absent engine reads as `null`; a corrupt file rejects and\nthe importer treats that as \"no text\". The repository's current `catalog:`\npin is 0.111.3, which predates the Office processors, so the host bumps the SDK\nset (and its minimum release age) before it can import `extractOffice`.\n\n## Manifest (`manifest.json`, version 1, closed)\n\n```json\n{ \"version\": 1, \"course\": { \"subject\": \"Math\" },\n \"sections\": [ { \"key\": \"unit-1\", \"title\": \"Unit One\",\n \"lessons\": [ { \"key\": \"sums\", \"title\": \"Sums\",\n \"members\": [ { \"path\": \"sheets/sums.pdf\", \"role\": \"exercise\" } ] } ] } ] }\n```\nEvery object is closed: an unknown key anywhere is `manifest_unknown_key` (with\nits path in `entry`); another version is `manifest_version`. Keys match\n`[A-Za-z0-9._:-]{1,120}` and are unique; roles are the extraction page roles\n(`exercise`, `example`, `instructions`, `cover`, `answer_key`, `mixed`,\n`unknown`); a listed path must exist and be listed once. The manifest is a source\nfact: roles are `source`, anything the extractor infers stays `inferred`, and a\nmanifest/extraction disagreement belongs in extraction review, not here. Without\na manifest each accepted member is one lesson, sections come from the top-level\ndirectory and order is natural path order (digit runs compare numerically, no\nlocale). Manifest order: the manifest, declared members, then the rest by path.\nLesson bodies are the readable text members of a lesson, joined; PDFs and images\nare never inlined. Members whose declared role is `answer_key` or `mixed` never\ncontribute text to a lesson body (they remain recorded evidence members); the\nproposal rationale only counts how many were withheld.\n\n## Manifest version 2 (SMRT #4615)\n\nA root `manifest.json` with `\"version\": 2` is laid out by the `manifest-v2`\nreader; version 1 keeps its own reader and page roles. The schema is closed in\nthe same way: an unknown key anywhere is `manifest_unknown_key` (with its path in\n`entry`), another version is `manifest_version`.\n\n```json\n{ \"version\": 2, \"course\": { \"subject\": \"Science\" },\n \"courseMembers\": [ { \"path\": \"unit/supplies.pdf\", \"role\": \"attachment\", \"title\": \"Supply list\" } ],\n \"sections\": [ { \"key\": \"unit\", \"title\": \"Unit\", \"lessons\": [ {\n \"key\": \"l1\", \"title\": \"Lesson 1\",\n \"members\": [ { \"path\": \"l1/log.docx\", \"role\": \"learner-pages\" },\n { \"path\": \"l1/deck.pptx\", \"role\": \"slides\" },\n { \"path\": \"l1/guidance.pdf\", \"role\": \"assessment-guidance\" } ],\n \"pages\": [ { \"member\": \"l1/log.docx\", \"from\": 1, \"to\": 4, \"label\": \"Mission log\" } ],\n \"videos\": [ { \"reference\": \"dQw4w9WgXcQ\", \"title\": \"Recap\", \"step\": 1 } ] } ] } ] }\n```\n\n- **Members** (lesson `members` and root `courseMembers`): `path` (must exist, listed\n once), `role` (required) one of `teacher-guide`, `slides`, `learner-pages`,\n `assessment`, `assessment-guidance`, `handout`, `printable`, `answer-key`,\n `attachment` (`manifest_role` otherwise; a video is a reference, never a file),\n optional `audience` and `title`. The audience is the role default\n (`LESSON_ASSET_DEFAULT_AUDIENCE`) unless stated; `answer-key` and\n `assessment-guidance` are always guardian (a learner audience is\n `manifest_audience`). Members not listed stay recorded evidence with no role.\n- **Pages**: `{ member, from, to, label? }` ranges over a member of the same lesson\n (PDF, Word or PowerPoint; the rendered PDF for Office). Each page becomes a\n `LessonPage` keyed `p<n>`, labelled `<label> <n>` (default `Page <n>`), ordered by\n declaration. Bad, overlapping or unpageable ranges are `manifest_pages`; at most\n 500 pages per lesson.\n- **Videos**: `{ reference, title, step, audience? }` where `reference` is a bare id\n or an allowlisted https YouTube URL (`parseYouTubeReference`; anything else is\n `manifest_video`), `step` an integer 0-9999. They become `LessonExternalVideo`\n rows; nothing is downloaded.\n- **Course members** become `CourseAsset` rows, not tied to any lesson.\n\nGuardian-audience text (teacher guides, guidance, keys) never enters a proposed\nlesson body; only learner-audience text members do. Under v2 `role` on the member\nevidence row is the material role and `audience` the effective audience (NULL on\nrows recorded before v2). The plan carries `materials` (also on\n`CourseBundleImportResult.materials`): the resolved materials, pages and videos,\nplus `skipped` for declared members that were unsupported, refused or failed\nrendering. A duplicate member resolves to the earlier identical member's bytes\n(`assetPath`) and one material per (place, role, content).\n\n`receiveMember` also gets `audience` and `courseLevel`. **Slides** keep the original\ndeck (hand `bytes` to the host as a private asset: \"Open in PowerPoint\") and its\nrendered pages (`rendered`: \"Present\").\n\n### Applying materials\n\n`applyMaterials({ ...actor, importId, bundle, courseId, lessons, assets })`\n(`education.course.author`, operation `apply-course-bundle-materials`) writes the\nrows in one transaction. The host has stored each accepted member as a private\nasset and created the course's lessons: `lessons` maps manifest lesson key to the\n`Lesson` id, `assets` maps member path to `{ original, rendered }` asset ids.\nSlides join both the original deck and the rendered PDF; the original join is always\nguardian-only whatever the deck's declared audience (the audience applies to the\nrendered PDF), and a join written earlier with a wider audience is repaired on\nre-apply (`repaired`); any other Word/PowerPoint\nmember joins only its rendered PDF, so a learner is never handed an editable\noriginal. `LessonAsset`/`CourseAsset` saves keep their private-asset rule (guardian-\nonly roles and personal-use-copy courses need private assets). It targets\nfamily-owned courses; an authored course is `authored-course` because authored\nmaterial changes through course authoring. Idempotent: an identical row is\n`unchanged`, a row with other details is `CourseBundleMaterialsError('conflict')`,\na missing asset or lesson is `asset-missing`/`lesson-missing`; any error writes\nnothing.\n\n### Retrying a failed rendering\n\n`retryRendering({ ...actor, importId, bundle, paths?, receiveMember? })`\n(`education.course.author`, operation `retry-course-bundle-rendering`, needs a\nconverter) renders the Word/PowerPoint members whose first import recorded a\n*transient* failure (`converter_unavailable`, `converter_failed`,\n`converter_timeout`; `COURSE_BUNDLE_RETRYABLE_REASONS`). The first import's member\nrows are never rewritten. A success appends one `course_bundle_renderings` row\nper (import, content hash); every reader (`readImport`, replayed imports,\nproposals) overlays it, so the member reads `accepted` (identical copies as\nduplicates) with its rendered hash. A failure records nothing, so asking again is\nalways allowed; a second retry of a repaired member is `unchanged` and renders\nnothing. Converter rejections, size limits and the Office member cap are not\nretryable: change the bundle. The bundle must be the imported one.\n\n## Readers\n\n`BundleReader { formatId, detect, prepare }` in a `BundleReaderRegistry`;\nbuilt-ins are `manifest-v2`, `manifest-v1` and `plain-files`. SCORM and Common Cartridge are\n**not implemented** (their manifests describe launch resources and activity\ntrees that do not map to sections/lessons without design); a host can register\na reader ahead of the built-ins (`register(reader, { first: true })`).\n\n## Identity and replay\n\n`bundleIdentity` (`cbi1-...`) digests the sorted (path, sha256) pairs, so\nre-zipping, reordering and timestamps replay. One `CourseBundleImport` per\n(tenant, identity); a concurrent first import replays once on the unique index.\n\n## Models (closed, required tenant, no REST/CLI/MCP/AI, append-only)\n\n`CourseBundleImport` (`course_bundle_imports`: identity, format, reader,\nmanifest version, counts, actor), `CourseBundleMember` (`course_bundle_members`:\nunique tenant/import/path; ordinal, sha256, size, media type, kind, status,\nreason, duplicate-of, role, audience, section and lesson key) and\n`CourseBundleRendering` (`course_bundle_renderings`: a deliberate retry's success). Hashes and placements only:\nbytes stay with the host's ingestion receipts. Rewrites and deletes throw.\nNormal manifest migrations create the tables and add `audience` (NULL on older rows).\n\n## Not in this release\n\n- Material rows for authored courses: a proposal still carries lesson text only;\n authored course material goes through `CourseMaterialInput` and release.\n- Ingestion's receipt contract is unchanged: there is no `application/zip` part\n type or `parentEvidenceId` in ingestion. The education side unpacks and the\n host receives members one by one, so archive-to-member lineage lives in\n `course_bundle_members` (and the host's receipts), not in ingestion evidence.\n Changing ingestion's published receipt is a separate design decision.\n- A manifest/extraction disagreement is not compared automatically.\n- SCORM / Common Cartridge readers; zip64; nested archives; encrypted zips.\n"
|
|
15697
|
+
},
|
|
15698
|
+
{
|
|
15699
|
+
"path": "agents/class-crunchlabs.md",
|
|
15700
|
+
"module": "class-crunchlabs",
|
|
15701
|
+
"content": "# Class CrunchLabs source (SMRT #4616)\n\nClass CrunchLabs is a global catalog provider (`class-crunchlabs`) whose units are\nlisted under Science, with the @ClassCrunchLabs YouTube course playlists as\npreviews. \"Add to a course\" reads the family's own download of the unit and runs\nthe [bundle import](course-bundle-import.md) with a manifest version 2 built from\nit. Files come from CrunchLabs' own downloads and YouTube; **nothing is hosted\nhere** and the listing has no acquirable formats.\n\n## Operator-only until permission, one public switch\n\n`CatalogVisibility` gains `operator`. A `CatalogPolicy` built with\n`operatorTenantIds` lets only those families (host configuration, never a\nrequest) see `operator` records; the default policy shows them to nobody, and\n`authorize` answers `operator-only`. Materialization refuses them.\n\n`createClassCrunchLabsCatalogAdapter({ playlists, publicListing? })` is a snapshot\n`CatalogSyncAdapter` (one terminal page, no cursor). By default\n(`CLASS_CRUNCHLABS_PUBLIC_LISTING_DEFAULT = false`) every record is `operator`\nvisibility with licence state `unknown`, no permitted operations and the text\n\"free curriculum, permission pending\". Only the literal `publicListing: true`,\nset by the operator in host configuration, makes records `catalog` visibility\nwith `verified` evidence permitting `link` only. The switch is part of each\nrecord's revision, so the next sync rewrites the rows when it is flipped.\n`classCrunchLabsPreviewEmbedUrl(record)` returns the youtube-nocookie playlist\nembed for a listed unit (null for anything else). The playlist data is an\ninjected `ClassCrunchLabsPlaylistSource`; the adapter never touches the network.\n\n`addClassCrunchLabsUnit({ service, actor, catalogActor, record, listing, policy,\nfiles, playlists, pageCounter?, receiveMember? })` re-checks\n`canAddClassCrunchLabsUnit(actor, policy, listing)`: visibility is derived from the\nhost's own `listing.publicListing` (operator unless the literal `true`), never from\nthe record or any caller-supplied visibility or licence; an operator-only listing\nis refused to every other family, and the course actor must be the catalog actor, builds the bundle and imports each part. Then propose\nstructure and `applyMaterials` per part as for any bundle. Courses are\n`personal-use-copy` (`build.licenseKind`).\n\n## Reading a download\n\n`buildClassCrunchLabsBundle({ files, playlists?, unit?, pageCounter?,\nincludeUnitWideAggregates?, maxPartBytes? })` takes a `ClassCrunchLabsFileSource`:\n`folderClassCrunchLabsSource(dir)` (Node only, from `@happyvertical/smrt-education/node`, never the root: a folder, the Drive zip parts, or both;\nzips are read lazily from their central directory, zip64/encrypted/symlinks are\nskipped) or `memoryClassCrunchLabsSource(files)`. It never reads video files.\n\n- Layout: `<Unit>_All Unit Wide Materials` and `<Unit>_Lesson <n>`; letters A\n teacher manual, B slides, C student mission log, D status check assessment\n (plus guidance and the editable master), E handouts, F extras, G videos. A\n lettered directory wins over a file's own letter. Several units need `unit`.\n- Mapping to manifest v2: A `teacher-guide`, B `slides`, C `learner-pages`, D\n `assessment` / `assessment-guidance`, E `handout`, F `printable`, the unit-wide\n Supply List `attachment`. Audiences are the role defaults (teacher manual and\n guidance guardian, slides guardian, mission log learner); the editable\n assessment master is guardian. Unit-wide files become course members.\n- The unit-wide \"(all lessons)\" combined files repeat the per-lesson files and\n are large, so they are left out unless `includeUnitWideAggregates` (reported as\n `aggregate-of-lessons`); the supply list, posters and other extras are kept.\n- Lesson pages: the first mission log of a lesson gets a `Mission log` range\n from `pageCounter` (`createClassCrunchLabsPageCounter({ converter? })` counts\n PDFs with pdf-lib and Word files by rendering them through the converter).\n Without a count the lesson gets no pages and the report says so.\n- Videos: names `Lnn.step_Video_Kind_Title` are matched, by unit (playlist\n title), lesson and step (`L03.6` or `Lesson 3 ... Step 6` in the item title),\n to the unit playlist; two videos sharing a step are told apart by the item\n title naming their title or kind, else both stay unmatched (`ambiguous`).\n Matches become `videos` references with a title and a step order (materials\n prep videos are guardian audience); unit-level videos (a teaser) and unmatched\n ones are only reported.\n- De-duplication: repeated exports of one logical path collapse (CRC and size\n when known; the newer export wins, a changed file is `superseded`); the same\n bytes under another name or place keep one copy (the lesson-named file in its\n lesson, posters unit wide, never a `copy`).\n- A unit larger than one archive (bundle cap 100 MiB) is split by lesson into\n `build.parts` (default 90 MiB each; the first part carries the course members and\n any lesson that fits beside them, others go to the first part with room; if no\n lesson fits beside the course members the build refuses with `too_large`); `build.bundle`\n and `build.manifest` are the first part.\n\nThe `report` lists lessons, members, parts, pages, matched and unmatched videos,\nduplicates, ignored files and warnings. Fixtures in tests are synthetic files\nwith the same naming; no real download is ever copied into the repository.\n\nTests: `src/__tests__/class-crunchlabs.test.ts`.\n"
|
|
15550
15702
|
},
|
|
15551
15703
|
{
|
|
15552
15704
|
"path": "agents/source-import.md",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ClassCrunchLabsFileSource } from './class-crunchlabs.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every unit file under `directory`: plain files, and the entries of any `.zip`
|
|
4
|
+
* (a zip is not expanded into the filesystem, and a zip inside a zip is not
|
|
5
|
+
* opened). Symbolic links are skipped; hidden files are ignored.
|
|
6
|
+
*/
|
|
7
|
+
export declare function folderClassCrunchLabsSource(directory: string): ClassCrunchLabsFileSource;
|
|
8
|
+
//# sourceMappingURL=class-crunchlabs-files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"class-crunchlabs-files.d.ts","sourceRoot":"","sources":["../../src/sources/class-crunchlabs-files.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAEV,yBAAyB,EAC1B,MAAM,uBAAuB,CAAC;AAiJ/B;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,GAChB,yBAAyB,CAgD3B"}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class CrunchLabs unit downloads to a course bundle (SMRT #4616).
|
|
3
|
+
*
|
|
4
|
+
* A unit download is a folder (or the zip parts a Drive export splits it into)
|
|
5
|
+
* holding `<Unit>_All Unit Wide Materials` and `<Unit>_Lesson <n>` folders. Files
|
|
6
|
+
* are lettered: A teacher manual, B slides, C student mission log, D status check
|
|
7
|
+
* assessment (plus guidance, editable), E handouts, F extras, G videos. The
|
|
8
|
+
* videos are not read: their names (`Lnn.step_Video_Kind_Title`) are matched,
|
|
9
|
+
* by unit, lesson and step, to the unit's YouTube course playlist and become
|
|
10
|
+
* external references.
|
|
11
|
+
*
|
|
12
|
+
* `buildClassCrunchLabsBundle` reads such a download and returns a manifest
|
|
13
|
+
* version 2 bundle for `CourseBundleImportService`. Nothing here touches the
|
|
14
|
+
* network: the playlist comes from an injected `ClassCrunchLabsPlaylistSource`,
|
|
15
|
+
* and files from an injected `ClassCrunchLabsFileSource` (Node folder and zip
|
|
16
|
+
* readers are in `class-crunchlabs-files.ts`). The files are CrunchLabs' own
|
|
17
|
+
* downloads, never hosted by us.
|
|
18
|
+
*/
|
|
19
|
+
import type { DocumentConverter, OfficeDocumentKind } from '../document-converter.js';
|
|
20
|
+
export declare const CLASS_CRUNCHLABS_PROVIDER_ID: "class-crunchlabs";
|
|
21
|
+
export declare const CLASS_CRUNCHLABS_SUBJECT = "Science";
|
|
22
|
+
/** The licence a family's own copy of a CrunchLabs unit carries. */
|
|
23
|
+
export declare const CLASS_CRUNCHLABS_LICENSE_KIND: "personal-use-copy";
|
|
24
|
+
export declare class ClassCrunchLabsError extends Error {
|
|
25
|
+
readonly code: 'no_unit' | 'multiple_units' | 'empty_unit' | 'too_large' | 'access_denied';
|
|
26
|
+
readonly detail: readonly string[];
|
|
27
|
+
constructor(code: 'no_unit' | 'multiple_units' | 'empty_unit' | 'too_large' | 'access_denied', message: string, detail?: readonly string[]);
|
|
28
|
+
}
|
|
29
|
+
/** One file of a unit download. Bytes are read lazily, and only for files that are used. */
|
|
30
|
+
export interface ClassCrunchLabsFile {
|
|
31
|
+
/** Path inside its source (folder-relative, or the zip entry name). */
|
|
32
|
+
path: string;
|
|
33
|
+
size: number;
|
|
34
|
+
/** CRC-32 (lower-case hex) when known without reading, used to spot repeated exports. */
|
|
35
|
+
crc32?: string;
|
|
36
|
+
/** Orders repeated exports of the same unit: the larger value is the newer export. */
|
|
37
|
+
exportId?: string;
|
|
38
|
+
read(): Promise<Uint8Array>;
|
|
39
|
+
}
|
|
40
|
+
export interface ClassCrunchLabsFileSource {
|
|
41
|
+
files(): Promise<ClassCrunchLabsFile[]>;
|
|
42
|
+
}
|
|
43
|
+
/** An in-memory source (tests, or a host that already holds the files). */
|
|
44
|
+
export declare function memoryClassCrunchLabsSource(files: ReadonlyArray<{
|
|
45
|
+
path: string;
|
|
46
|
+
bytes?: Uint8Array;
|
|
47
|
+
size?: number;
|
|
48
|
+
exportId?: string;
|
|
49
|
+
crc32?: string;
|
|
50
|
+
}>): ClassCrunchLabsFileSource;
|
|
51
|
+
export interface ClassCrunchLabsPlaylistItem {
|
|
52
|
+
videoId: string;
|
|
53
|
+
title: string;
|
|
54
|
+
position: number;
|
|
55
|
+
}
|
|
56
|
+
/** A YouTube course playlist of the @ClassCrunchLabs channel. */
|
|
57
|
+
export interface ClassCrunchLabsPlaylist {
|
|
58
|
+
id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
/** The unit the playlist belongs to when the source knows it; else derived from the title. */
|
|
61
|
+
unit?: string;
|
|
62
|
+
items: ClassCrunchLabsPlaylistItem[];
|
|
63
|
+
}
|
|
64
|
+
/** Injected: tests use fixtures, a host fetches the channel's playlists. Never called from a test with a network. */
|
|
65
|
+
export interface ClassCrunchLabsPlaylistSource {
|
|
66
|
+
playlists(): Promise<ClassCrunchLabsPlaylist[]>;
|
|
67
|
+
}
|
|
68
|
+
export type ClassCrunchLabsPageCounter = (input: {
|
|
69
|
+
bytes: Uint8Array;
|
|
70
|
+
kind: 'pdf' | OfficeDocumentKind;
|
|
71
|
+
}) => Promise<number | null>;
|
|
72
|
+
/** Case, punctuation and spacing insensitive key. */
|
|
73
|
+
export declare const classCrunchLabsKey: (value: string) => string;
|
|
74
|
+
export interface ClassCrunchLabsVideoName {
|
|
75
|
+
lesson: number;
|
|
76
|
+
/** Lower-case step such as `6` or `1b`. */
|
|
77
|
+
step: string;
|
|
78
|
+
/** The kind token as named, for example `MaterialsPrep`. */
|
|
79
|
+
kind: string;
|
|
80
|
+
/** Humanized title, `''` when the name has none. */
|
|
81
|
+
title: string;
|
|
82
|
+
}
|
|
83
|
+
/** Parse `Lnn.step_Video_Kind_Title_Unit.mp4`; null when the name is not a lesson video. */
|
|
84
|
+
export declare function parseClassCrunchLabsVideoName(fileName: string, unit: string): ClassCrunchLabsVideoName | null;
|
|
85
|
+
export interface ClassCrunchLabsVideoMatch {
|
|
86
|
+
lesson: number;
|
|
87
|
+
step: string;
|
|
88
|
+
kind: string;
|
|
89
|
+
title: string;
|
|
90
|
+
videoId: string;
|
|
91
|
+
playlistItem: string;
|
|
92
|
+
}
|
|
93
|
+
export interface ClassCrunchLabsVideoMiss {
|
|
94
|
+
lesson: number;
|
|
95
|
+
step: string;
|
|
96
|
+
kind: string;
|
|
97
|
+
title: string;
|
|
98
|
+
reason: 'no_playlist' | 'no_item' | 'ambiguous' | 'item_taken';
|
|
99
|
+
}
|
|
100
|
+
/** The (lesson, step) a playlist item title names: `L03.6`, `Lesson 3 ... Step 6`. */
|
|
101
|
+
export declare function parsePlaylistItemTitle(title: string): {
|
|
102
|
+
lesson: number;
|
|
103
|
+
step: string;
|
|
104
|
+
} | null;
|
|
105
|
+
/** The playlist for a unit: its title (or `unit`) names the unit; the shortest title wins. */
|
|
106
|
+
export declare function pickUnitPlaylist(playlists: readonly ClassCrunchLabsPlaylist[], unit: string): ClassCrunchLabsPlaylist | null;
|
|
107
|
+
/**
|
|
108
|
+
* Match lesson videos to playlist items by lesson and step. Two videos that
|
|
109
|
+
* share a step (a materials-prep and a hands-on video) are told apart by the
|
|
110
|
+
* item title naming their title or kind; if that cannot decide, they are left
|
|
111
|
+
* unmatched as `ambiguous` rather than guessed. Each item is used once.
|
|
112
|
+
*/
|
|
113
|
+
export declare function matchClassCrunchLabsVideos(videos: readonly ClassCrunchLabsVideoName[], playlist: ClassCrunchLabsPlaylist | null): {
|
|
114
|
+
matched: ClassCrunchLabsVideoMatch[];
|
|
115
|
+
unmatched: ClassCrunchLabsVideoMiss[];
|
|
116
|
+
unusedItems: number;
|
|
117
|
+
};
|
|
118
|
+
export interface ClassCrunchLabsBuildOptions {
|
|
119
|
+
files: ClassCrunchLabsFileSource;
|
|
120
|
+
/** Without one, videos are reported unmatched (`no_playlist`) and no reference is written. */
|
|
121
|
+
playlists?: ClassCrunchLabsPlaylistSource;
|
|
122
|
+
/** Which unit when the download holds several; matched ignoring case and punctuation. */
|
|
123
|
+
unit?: string;
|
|
124
|
+
/**
|
|
125
|
+
* How many pages a mission log has, to write its lesson page range. Without
|
|
126
|
+
* one, or when it returns null, the lesson gets no page rows (a warning says so).
|
|
127
|
+
*/
|
|
128
|
+
pageCounter?: ClassCrunchLabsPageCounter;
|
|
129
|
+
/**
|
|
130
|
+
* The unit-wide "(all lessons)" files (the combined manual, deck, mission log,
|
|
131
|
+
* status checks and handouts) repeat the per-lesson files and are large, so they
|
|
132
|
+
* are left out by default. The supply list, posters and other unit-wide extras
|
|
133
|
+
* are always course members.
|
|
134
|
+
*/
|
|
135
|
+
includeUnitWideAggregates?: boolean;
|
|
136
|
+
/** Largest bundle part in bytes; default 90 MiB, never above the bundle archive limit. */
|
|
137
|
+
maxPartBytes?: number;
|
|
138
|
+
}
|
|
139
|
+
export interface ClassCrunchLabsReport {
|
|
140
|
+
unit: string;
|
|
141
|
+
lessons: number;
|
|
142
|
+
members: number;
|
|
143
|
+
courseMembers: number;
|
|
144
|
+
/** Bundles the unit was split into (1 unless it is large). */
|
|
145
|
+
parts: number;
|
|
146
|
+
pages: Array<{
|
|
147
|
+
lesson: number;
|
|
148
|
+
path: string;
|
|
149
|
+
pages: number;
|
|
150
|
+
}>;
|
|
151
|
+
videos: {
|
|
152
|
+
playlist: {
|
|
153
|
+
id: string;
|
|
154
|
+
title: string;
|
|
155
|
+
} | null;
|
|
156
|
+
matched: ClassCrunchLabsVideoMatch[];
|
|
157
|
+
unmatched: ClassCrunchLabsVideoMiss[];
|
|
158
|
+
unusedPlaylistItems: number;
|
|
159
|
+
/** Unit-level videos (a teaser): a course has no video of its own, so none is written. */
|
|
160
|
+
unitLevel: string[];
|
|
161
|
+
};
|
|
162
|
+
/** Same bytes under another name or place: only the preferred copy is bundled. */
|
|
163
|
+
duplicates: Array<{
|
|
164
|
+
path: string;
|
|
165
|
+
duplicateOf: string;
|
|
166
|
+
}>;
|
|
167
|
+
/** The same file in more than one export of the unit. */
|
|
168
|
+
duplicateExports: number;
|
|
169
|
+
/** A file that differs between exports: the newer export is used. */
|
|
170
|
+
superseded: number;
|
|
171
|
+
ignored: Array<{
|
|
172
|
+
path: string;
|
|
173
|
+
reason: string;
|
|
174
|
+
}>;
|
|
175
|
+
warnings: string[];
|
|
176
|
+
}
|
|
177
|
+
/** One importable bundle: a manifest version 2 and the files it names. */
|
|
178
|
+
export interface ClassCrunchLabsBundlePart {
|
|
179
|
+
bundle: {
|
|
180
|
+
files: Array<{
|
|
181
|
+
path: string;
|
|
182
|
+
bytes: Uint8Array;
|
|
183
|
+
}>;
|
|
184
|
+
};
|
|
185
|
+
manifest: Record<string, unknown>;
|
|
186
|
+
/** The lesson numbers this part carries. */
|
|
187
|
+
lessons: number[];
|
|
188
|
+
}
|
|
189
|
+
export interface ClassCrunchLabsBuild {
|
|
190
|
+
unit: string;
|
|
191
|
+
/**
|
|
192
|
+
* The bundles to import, in order, each with `CourseBundleImportService`. A
|
|
193
|
+
* unit that fits one archive is one part; a large one is split by lesson
|
|
194
|
+
* (course-level members go in the first part), never by file.
|
|
195
|
+
*/
|
|
196
|
+
parts: ClassCrunchLabsBundlePart[];
|
|
197
|
+
/** The first part, for the usual single-bundle unit. */
|
|
198
|
+
bundle: ClassCrunchLabsBundlePart['bundle'];
|
|
199
|
+
manifest: Record<string, unknown>;
|
|
200
|
+
/** A family's own copy of a unit is a personal-use copy. */
|
|
201
|
+
licenseKind: typeof CLASS_CRUNCHLABS_LICENSE_KIND;
|
|
202
|
+
report: ClassCrunchLabsReport;
|
|
203
|
+
}
|
|
204
|
+
/** Read a Class CrunchLabs unit download and build a manifest version 2 bundle. */
|
|
205
|
+
export declare function buildClassCrunchLabsBundle(options: ClassCrunchLabsBuildOptions): Promise<ClassCrunchLabsBuild>;
|
|
206
|
+
/**
|
|
207
|
+
* A page counter for mission logs: PDFs are counted directly (`pdf-lib`), Word
|
|
208
|
+
* and PowerPoint files by asking the injected converter to render them. Without
|
|
209
|
+
* a converter, only PDFs are counted. The count is a convenience for the page
|
|
210
|
+
* range; the importer still renders the member itself.
|
|
211
|
+
*/
|
|
212
|
+
export declare function createClassCrunchLabsPageCounter(options?: {
|
|
213
|
+
converter?: DocumentConverter;
|
|
214
|
+
timeoutMs?: number;
|
|
215
|
+
maxBytes?: number;
|
|
216
|
+
}): ClassCrunchLabsPageCounter;
|
|
217
|
+
//# sourceMappingURL=class-crunchlabs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"class-crunchlabs.d.ts","sourceRoot":"","sources":["../../src/sources/class-crunchlabs.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-education",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "Tenant-aware learning catalog models and authorized catalog access for SMRT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,21 +22,25 @@
|
|
|
22
22
|
"./ingestion": {
|
|
23
23
|
"types": "./dist/ingestion.d.ts",
|
|
24
24
|
"import": "./dist/ingestion.js"
|
|
25
|
+
},
|
|
26
|
+
"./node": {
|
|
27
|
+
"types": "./dist/node.d.ts",
|
|
28
|
+
"import": "./dist/node.js"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
31
|
"dependencies": {
|
|
28
|
-
"@happyvertical/smrt-assets": "0.
|
|
29
|
-
"@happyvertical/smrt-core": "0.
|
|
30
|
-
"@happyvertical/smrt-profiles": "0.
|
|
31
|
-
"@happyvertical/smrt-tenancy": "0.
|
|
32
|
-
"@happyvertical/smrt-users": "0.
|
|
32
|
+
"@happyvertical/smrt-assets": "0.59.0",
|
|
33
|
+
"@happyvertical/smrt-core": "0.59.0",
|
|
34
|
+
"@happyvertical/smrt-profiles": "0.59.0",
|
|
35
|
+
"@happyvertical/smrt-tenancy": "0.59.0",
|
|
36
|
+
"@happyvertical/smrt-users": "0.59.0",
|
|
33
37
|
"@happyvertical/files": "^0.111.3",
|
|
34
38
|
"@happyvertical/sql": "^0.111.3",
|
|
35
39
|
"pdf-lib": "1.17.1",
|
|
36
|
-
"@happyvertical/smrt-ingestion": "0.
|
|
40
|
+
"@happyvertical/smrt-ingestion": "0.59.0"
|
|
37
41
|
},
|
|
38
42
|
"devDependencies": {
|
|
39
|
-
"@happyvertical/smrt-vitest": "0.
|
|
43
|
+
"@happyvertical/smrt-vitest": "0.59.0",
|
|
40
44
|
"@types/node": "24.13.6",
|
|
41
45
|
"typescript": "6.0.3",
|
|
42
46
|
"vite": "8.1.6",
|