diff --git a/README.md b/README.md index 5114724..3dc2c29 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ A Tauri desktop application for creating and using flashcard like informational ## JSON Schema -The application uses JSON files to store lesson data. Here's the schema: +The application uses JSON files to store lesson data. There are two types of JSON files: + +### 1. Lesson JSON Schema (lesson.json) + +Individual lessons with items: ```json { @@ -46,8 +50,50 @@ The application uses JSON files to store lesson data. Here's the schema: } ``` +### 2. Parent Training JSON Schema (training.json) + +Parent containers that link multiple lessons together: + +```json +{ + "meta": { + "class_id": "string", + "title": "string", + "date": "string (YYYY-MM-DD)", + "description": "string", + "custom_order": "string" + }, + "children": [ + { + "lesson_id": "string", + "default_order": "number", + "title": "string", + "gated": "boolean", + "gates": [ + { + "type": "string", + "payload": { + "speed": "number" + } + } + ], + "image": "string (path to image)", + "actions": [ + { + "type": "string", + "payload": { + "speed": "number" + } + } + ] + } + ] +} +``` + ### Field Descriptions +#### Lesson JSON Fields - `meta.lesson_id`: Unique identifier for the lesson - `meta.title`: Display name for the lesson - `meta.date`: Creation date in YYYY-MM-DD format @@ -61,6 +107,20 @@ The application uses JSON files to store lesson data. Here's the schema: - `items[].actions[].type`: Type of action (e.g., "flash") - `items[].actions[].payload.speed`: Speed parameter for the action +#### Parent Training JSON Fields +- `meta.class_id`: Unique identifier for the training class +- `meta.title`: Display name for the training series +- `meta.date`: Creation date in YYYY-MM-DD format +- `meta.description`: Description of the training series +- `meta.custom_order`: Custom ordering preference for lessons +- `children[].lesson_id`: Reference to a specific lesson +- `children[].default_order`: Default display order for the lesson +- `children[].title`: Display title for the lesson +- `children[].gated`: Whether the lesson requires completion of previous lessons +- `children[].gates`: Array of gate conditions that must be met +- `children[].image`: Path to the lesson image +- `children[].actions`: Array of actions to perform with the lesson + ## Development ### Prerequisites diff --git a/USER-STORIES.md b/USER-STORIES.md index 55931ae..e274401 100644 --- a/USER-STORIES.md +++ b/USER-STORIES.md @@ -396,4 +396,163 @@ Acceptance criteria: Acceptance criteria: -- A new parent data type is available to link multiple lessons together. \ No newline at end of file +- A new parent data type is available to link multiple lessons together. + +**COMPLETED**: +- Added new Rust structs: `TrainingMeta`, `TrainingChild`, and `ParentTrainingData` for the parent data type +- Updated `static/classes/test-1/training.json` to match the new schema with children array +- Updated parsing logic in `get_learning_paths()` to handle both lesson.json and training.json files +- Updated `load_training_data()` function to return appropriate data type based on file type +- Enhanced "New" page display to show "(Multi-Lesson)" indicator for training.json files +- Updated README.md documentation to include both lesson.json and training.json schemas +- Training.json files now show lesson count in description (e.g., "Contains 2 linked lessons") +- **ENHANCED**: Updated editor page to handle both lesson.json and training.json data types +- **ENHANCED**: Created new `TrainingChildNode` component for editing training child nodes +- **ENHANCED**: Editor now automatically detects data type and creates appropriate node structure +- **ENHANCED**: Training.json files show children as editable nodes with gates and actions +- **ENHANCED**: Different edge colors for lesson items (red) vs training children (purple) +- Verified successful build and integration with existing functionality +' + +# Stories still in draft + +### Story # + +`As a user the directory storage style is cumbersome there should be a zip format that can handle collecting and compressing our classes. + +- The zip file should end in the extension `.lrn` but remain a zip formatted archive +- The zip file contains the `meta` fields of the `training.json` or `lesson.json` in file headers "Extra" field +- For a `training.json` the children lessons are zipped up separately inside the parent zip +- All instances of opening or saving a lesson or training use +` + +### Story # + +'As a user the training page should be updated to handle the new parent data type. + +- The training page should now be able to parse the `training.json` file and iterate through the children +- The children should be displayed in the order of the `default_order` field +- The training page should display the `title` and `image` of the child before starting the lesson +- The training page should then iterate through the items of the lesson linked by the `lesson_id` +- The training page should then return to the parent training page to display the next child + +Acceptance criteria: + +- The training page can now handle the new parent data type +- The training page can now iterate through the children of the parent data type +- The training page can now iterate through the items of the lesson linked by the `lesson_id` +' + +### Story # + +'As a user the editor page should be updated to handle the new parent data type. + +- The editor page should now be able to parse the `training.json` file and create the appropriate nodes +- The editor page should create a parent node for the `meta` section of the `training.json` file +- The editor page should create a child node for each of the `children` in the `training.json` file +- The editor page should link the parent node to the first child node +- The editor page should link each child node to the next child node in the order of the `default_order` field + +Acceptance criteria: + +- The editor page can now handle the new parent data type +- The editor page can now create the appropriate nodes for the parent data type +- The editor page can now link the nodes in the correct order +' + +### Story # + +'As a user the application is not saving any of the edits I make in the editor. + +- Add a save button to the editor page that will save the changes to the file +- The save button should be disabled if there are no changes to the file +- The save button should be enabled if there are changes to the file +- The save button should save the changes to the file and then disable itself +- The save button should also update the global state of the application with the new data + +Acceptance criteria: + +- The editor page now has a save button +- The save button is disabled if there are no changes to the file +- The save button is enabled if there are changes to the file +- The save button saves the changes to the file and then disables itself +- The save button updates the global state of the application with the new data +' + +### Story # + +'As a user the application is not creating new files. + +- Add a create button to the editor page that will create a new file +- The create button should open a dialogue modal for a new lesson name +- The create button should then create a new file with the given name +- The create button should then open the new file in the editor +- The create button should also update the global state of the application with the new data + +Acceptance criteria: + +- The editor page now has a create button +- The create button opens a dialogue modal for a new lesson name +- The create button creates a new file with the given name +- The create button opens the new file in the editor +- The create button updates the global state of the application with the new data +' + +### Story # + +'As a user the application is not deleting files. + +- Add a delete button to the editor page that will delete the file +- The delete button should be disabled if there is no file open +- The delete button should be enabled if there is a file open +- The delete button should ask for confirmation before deleting the file +- The delete button should delete the file and then close the editor +- The delete button should also update the global state of the application + +Acceptance criteria: + +- The editor page now has a delete button +- The delete button is disabled if there is no file open +- The delete button is enabled if there is a file open +- The delete button asks for confirmation before deleting the file +- The delete button deletes the file and then closes the editor +- The delete button updates the global state of the application +' + +### Story # + +'As a user the application is not importing files. + +- Add an import button to the editor page that will import a file +- The import button should open a file picker widget to get the file path +- The import button should then copy the file to the lessons directory +- The import button should then open the new file in the editor +- The import button should also update the global state of the application with the new data + +Acceptance criteria: + +- The editor page now has an import button +- The import button opens a file picker widget to get the file path +- The import button copies the file to the lessons directory +- The import button opens the new file in the editor +- The import button updates the global state of the application with the new data +' + + + +### Story # + +'As a user the application is not exporting files. + +- Add an export button to the editor page that will export a file +- The export button should open a file picker widget to get the file path +- The export button should then copy the file to the selected path +- The export button should also update the global state of the application with the new data + +Acceptance criteria: + +- The editor page now has an export button +- The export button opens a file picker widget to get the file path +- The export button copies the file to the selected path +- The export button updates the global state of the application with the new data +' diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 777ebcd..a4e10a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,33 @@ pub struct TrainingItem { pub actions: Vec, } +// New parent data type for linking multiple lessons +#[derive(Debug, Serialize, Deserialize)] +pub struct TrainingMeta { + pub class_id: String, + pub title: String, + pub date: String, + pub description: String, + pub custom_order: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TrainingChild { + pub lesson_id: String, + pub default_order: u32, + pub title: String, + pub gated: bool, + pub gates: Vec, + pub image: String, + pub actions: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ParentTrainingData { + pub meta: TrainingMeta, + pub children: Vec, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Action { #[serde(rename = "type")] @@ -109,41 +136,70 @@ fn get_learning_paths() -> Result, String> { println!("Found directory: {}", _dir_name); - // Look for lesson.json in this directory (skip training.json until Story 13) + // Look for both lesson.json and training.json in this directory let lesson_json_path = path.join("lesson.json"); + let training_json_path = path.join("training.json"); let json_path = if lesson_json_path.exists() { directories_with_lesson_json += 1; directories_with_json += 1; println!("Found lesson.json in: {}", lesson_json_path.display()); - Some(lesson_json_path) + Some((lesson_json_path, "lesson")) + } else if training_json_path.exists() { + directories_with_training_json += 1; + directories_with_json += 1; + println!("Found training.json in: {}", training_json_path.display()); + Some((training_json_path, "training")) } else { directories_without_json += 1; - println!("No lesson.json found in: {}", path.display()); + println!("No lesson.json or training.json found in: {}", path.display()); None }; - if let Some(json_path) = json_path { + if let Some((json_path, file_type)) = json_path { let json_content = fs::read_to_string(&json_path) .map_err(|e| format!("Failed to read {}: {}", json_path.display(), e))?; println!("JSON content length: {} characters", json_content.len()); println!("JSON content preview: {}", &json_content[..json_content.len().min(200)]); - let training_data: TrainingData = serde_json::from_str(&json_content) - .map_err(|e| { - println!("JSON parsing error: {}", e); - format!("Failed to parse JSON in {}: {}", json_path.display(), e) - })?; - - println!("Successfully parsed training data for: {}", training_data.meta.title); - - learning_paths.push(LearningPath { - id: _dir_name.to_string(), - title: training_data.meta.title, - date: training_data.meta.date, - description: training_data.meta.description, - }); + match file_type { + "lesson" => { + let training_data: TrainingData = serde_json::from_str(&json_content) + .map_err(|e| { + println!("JSON parsing error: {}", e); + format!("Failed to parse lesson JSON in {}: {}", json_path.display(), e) + })?; + + println!("Successfully parsed lesson data for: {}", training_data.meta.title); + + learning_paths.push(LearningPath { + id: _dir_name.to_string(), + title: training_data.meta.title, + date: training_data.meta.date, + description: training_data.meta.description, + }); + }, + "training" => { + let parent_training_data: ParentTrainingData = serde_json::from_str(&json_content) + .map_err(|e| { + println!("JSON parsing error: {}", e); + format!("Failed to parse training JSON in {}: {}", json_path.display(), e) + })?; + + println!("Successfully parsed training data for: {}", parent_training_data.meta.title); + + learning_paths.push(LearningPath { + id: _dir_name.to_string(), + title: format!("{} (Multi-Lesson)", parent_training_data.meta.title), + date: parent_training_data.meta.date, + description: format!("{} - Contains {} linked lessons", parent_training_data.meta.description, parent_training_data.children.len()), + }); + }, + _ => { + return Err(format!("Unknown file type: {}", file_type)); + } + } } } } @@ -153,7 +209,7 @@ fn get_learning_paths() -> Result, String> { println!("Total directories found: {}", total_directories); println!("Directories with JSON files: {}", directories_with_json); println!(" - Directories with lesson.json: {}", directories_with_lesson_json); - println!(" - Directories with training.json: 0 (skipped until Story 13)"); + println!(" - Directories with training.json: {}", directories_with_training_json); println!("Directories without JSON files: {}", directories_without_json); println!("Total learning paths successfully loaded: {}", learning_paths.len()); println!("================================="); @@ -162,7 +218,7 @@ fn get_learning_paths() -> Result, String> { } #[tauri::command] -fn load_training_data(class_id: String) -> Result { +fn load_training_data(class_id: String) -> Result { // Try multiple possible paths for the classes directory let possible_paths = vec![ "static/classes", @@ -199,26 +255,42 @@ fn load_training_data(class_id: String) -> Result { let training_json_path = class_path.join("training.json"); let lesson_json_path = class_path.join("lesson.json"); - let json_path = if training_json_path.exists() { + let (json_path, file_type) = if training_json_path.exists() { println!("Found training.json in class '{}'", class_id); - training_json_path + (training_json_path, "training") } else if lesson_json_path.exists() { println!("Found lesson.json in class '{}'", class_id); - lesson_json_path + (lesson_json_path, "lesson") } else { return Err(format!("Neither training.json nor lesson.json found in class '{}'", class_id)); }; - println!("Loading training data from: {}", json_path.display()); + println!("Loading data from: {}", json_path.display()); let json_content = fs::read_to_string(&json_path) .map_err(|e| format!("Failed to read {}: {}", json_path.display(), e))?; - let training_data: TrainingData = serde_json::from_str(&json_content) - .map_err(|e| format!("Failed to parse JSON in {}: {}", json_path.display(), e))?; - - println!("Successfully loaded training data for: {}", training_data.meta.title); - Ok(training_data) + match file_type { + "lesson" => { + let training_data: TrainingData = serde_json::from_str(&json_content) + .map_err(|e| format!("Failed to parse lesson JSON in {}: {}", json_path.display(), e))?; + + println!("Successfully loaded lesson data for: {}", training_data.meta.title); + Ok(serde_json::to_value(training_data) + .map_err(|e| format!("Failed to serialize lesson data: {}", e))?) + }, + "training" => { + let parent_training_data: ParentTrainingData = serde_json::from_str(&json_content) + .map_err(|e| format!("Failed to parse training JSON in {}: {}", json_path.display(), e))?; + + println!("Successfully loaded training data for: {}", parent_training_data.meta.title); + Ok(serde_json::to_value(parent_training_data) + .map_err(|e| format!("Failed to serialize training data: {}", e))?) + }, + _ => { + return Err(format!("Unknown file type: {}", file_type)); + } + } } #[tauri::command] diff --git a/src/lib/TrainingChildNode.svelte b/src/lib/TrainingChildNode.svelte new file mode 100644 index 0000000..8fe69fe --- /dev/null +++ b/src/lib/TrainingChildNode.svelte @@ -0,0 +1,355 @@ + + +
+ + +
+
+

{data.label}

+ +
+
+ {data.child.lesson_id} + {#if data.child.gated} + 🔒 + {/if} +
+
+ + {#if isExpanded} +
+
+ + handleInputChange('lesson_id', e.target.value)} + /> +
+
+ + handleInputChange('title', e.target.value)} + /> +
+
+ + handleInputChange('default_order', parseInt(e.target.value))} + /> +
+
+ +
+ +
+
+
+ +
+ handleInputChange('image', e.target.value)} + /> +
+
+ + +
+
Gates ({data.child.gates.length})
+
+ {#each data.child.gates as gate, gateIndex} +
+
+ + handleGateChange(gateIndex, 'type', e.target.value)} + /> +
+
+ + handleGateChange(gateIndex, 'speed', e.target.value)} + /> +
+
+ {/each} + + +
+
Actions ({data.child.actions.length})
+
+ {#each data.child.actions as action, actionIndex} +
+
+ + handleActionChange(actionIndex, 'type', e.target.value)} + /> +
+
+ + handleActionChange(actionIndex, 'speed', e.target.value)} + /> +
+
+ {/each} +
+ {/if} + + +
+ + \ No newline at end of file diff --git a/src/routes/editor/+page.svelte b/src/routes/editor/+page.svelte index b2f3002..4373a83 100644 --- a/src/routes/editor/+page.svelte +++ b/src/routes/editor/+page.svelte @@ -7,6 +7,7 @@ import dagre from 'dagre'; import MetaNode from '$lib/MetaNode.svelte'; import ItemNode from '$lib/ItemNode.svelte'; + import TrainingChildNode from '$lib/TrainingChildNode.svelte'; import CustomEdge from '$lib/CustomEdge.svelte'; let isLoaded = $state(false); @@ -22,7 +23,8 @@ let edges = $state([]); let nodeTypes = $state({ metaNode: MetaNode, - itemNode: ItemNode + itemNode: ItemNode, + trainingChildNode: TrainingChildNode }); let edgeTypes = $state({ customEdge: CustomEdge @@ -104,69 +106,148 @@ console.log('Creating nodes from data:', data); - // Create metadata node (parent) - const metaNode = { - id: 'meta', - type: 'metaNode', - position: { x: 400, y: 100 }, - data: { - label: 'Metadata', - meta: data.meta - } - }; - newNodes.push(metaNode); - console.log('Created meta node:', metaNode); + // Detect data type based on structure + const isTrainingData = data.children && Array.isArray(data.children); + const isLessonData = data.items && Array.isArray(data.items); - // Create item nodes (children) - data.items.forEach((item, index) => { - const itemNode = { - id: `item-${item.item_id}`, - type: 'itemNode', - position: { x: 400, y: 350 + (index * 300) }, + console.log('Data type detection:', { isTrainingData, isLessonData }); + + if (isTrainingData) { + // Handle training.json (parent data type) + console.log('Processing training data with children'); + + // Create metadata node (parent) + const metaNode = { + id: 'meta', + type: 'metaNode', + position: { x: 400, y: 100 }, data: { - label: `Item ${item.item_id}`, - item: item + label: 'Training Metadata', + meta: data.meta } }; - newNodes.push(itemNode); - console.log(`Created item node ${index}:`, itemNode); + newNodes.push(metaNode); + console.log('Created training meta node:', metaNode); - // Create edge from metadata to first item, or from previous item to current item - if (index === 0) { - const edge = { - id: `edge-meta-${item.item_id}`, - source: 'meta', - target: `item-${item.item_id}`, - type: 'customEdge', - style: { - stroke: '#ff0000', - strokeWidth: 4, - strokeDasharray: '10,5' - }, - animated: true + // Create training child nodes + data.children.forEach((child, index) => { + const childNode = { + id: `child-${child.lesson_id}`, + type: 'trainingChildNode', + position: { x: 400, y: 350 + (index * 300) }, + data: { + label: `Lesson: ${child.title}`, + child: child + } }; - newEdges.push(edge); - console.log('Created edge from meta to first item:', edge); - } else { - const prevItem = data.items[index - 1]; - console.log('prevItem', prevItem); - console.log('item', item); - const edge = { - id: `edge-${prevItem.item_id}-${item.item_id}`, - source: `item-${prevItem.item_id}`, - target: `item-${item.item_id}`, - type: 'customEdge', - style: { - stroke: '#ff0000', - strokeWidth: 4, - strokeDasharray: '10,5' - }, - animated: true + newNodes.push(childNode); + console.log(`Created training child node ${index}:`, childNode); + + // Create edge from metadata to first child, or from previous child to current child + if (index === 0) { + const edge = { + id: `edge-meta-${child.lesson_id}`, + source: 'meta', + target: `child-${child.lesson_id}`, + type: 'customEdge', + style: { + stroke: '#8e44ad', + strokeWidth: 4, + strokeDasharray: '10,5' + }, + animated: true + }; + newEdges.push(edge); + console.log('Created edge from meta to first child:', edge); + } else { + const prevChild = data.children[index - 1]; + const edge = { + id: `edge-${prevChild.lesson_id}-${child.lesson_id}`, + source: `child-${prevChild.lesson_id}`, + target: `child-${child.lesson_id}`, + type: 'customEdge', + style: { + stroke: '#8e44ad', + strokeWidth: 4, + strokeDasharray: '10,5' + }, + animated: true + }; + newEdges.push(edge); + console.log(`Created edge from child ${prevChild.lesson_id} to child ${child.lesson_id}:`, edge); + } + }); + + } else if (isLessonData) { + // Handle lesson.json (individual lesson data) + console.log('Processing lesson data with items'); + + // Create metadata node (parent) + const metaNode = { + id: 'meta', + type: 'metaNode', + position: { x: 400, y: 100 }, + data: { + label: 'Lesson Metadata', + meta: data.meta + } + }; + newNodes.push(metaNode); + console.log('Created lesson meta node:', metaNode); + + // Create item nodes (children) + data.items.forEach((item, index) => { + const itemNode = { + id: `item-${item.item_id}`, + type: 'itemNode', + position: { x: 400, y: 350 + (index * 300) }, + data: { + label: `Item ${item.item_id}`, + item: item + } }; - newEdges.push(edge); - console.log(`Created edge from item ${prevItem.item_id} to item ${item.item_id}:`, edge); - } - }); + newNodes.push(itemNode); + console.log(`Created item node ${index}:`, itemNode); + + // Create edge from metadata to first item, or from previous item to current item + if (index === 0) { + const edge = { + id: `edge-meta-${item.item_id}`, + source: 'meta', + target: `item-${item.item_id}`, + type: 'customEdge', + style: { + stroke: '#ff0000', + strokeWidth: 4, + strokeDasharray: '10,5' + }, + animated: true + }; + newEdges.push(edge); + console.log('Created edge from meta to first item:', edge); + } else { + const prevItem = data.items[index - 1]; + const edge = { + id: `edge-${prevItem.item_id}-${item.item_id}`, + source: `item-${prevItem.item_id}`, + target: `item-${item.item_id}`, + type: 'customEdge', + style: { + stroke: '#ff0000', + strokeWidth: 4, + strokeDasharray: '10,5' + }, + animated: true + }; + newEdges.push(edge); + console.log(`Created edge from item ${prevItem.item_id} to item ${item.item_id}:`, edge); + } + }); + + } else { + console.error('Unknown data structure:', data); + throw new Error('Unknown data structure - neither items nor children found'); + } console.log('Final nodes array:', newNodes); console.log('Final edges array:', newEdges); diff --git a/static/classes/test-1/training.json b/static/classes/test-1/training.json index 44ee32c..aa1ea55 100644 --- a/static/classes/test-1/training.json +++ b/static/classes/test-1/training.json @@ -1,15 +1,25 @@ { "meta": { - "class_id": "test-1", - "title": "Marcus Aurelius Quotes", + "class_id": "prompt-engineering-1", + "title": "Prompt Engineering 1", "date": "2025-06-29", - "description": "This is a test of the flashbrain app. It is a simple app that allows you to create flashcards with images and text. The app will then flash the image and text for a given duration and speed.", - "seconds_per_word": 0.5 + "description": "A series of prompt engineering frameworks and patterns.", + "custom_order": "sequential" }, - "items": [ + "children": [ { - "item_id": "1", - "text": "Men exist for the sake of one another. Teach them then or bear with them.", + "lesson_id": "google-prompt-tcrei", + "default_order": 1, + "title": "Review: Task Context References Evaluate Iterate", + "gated": false, + "gates": [ + { + "type": "completion", + "payload": { + "speed": 5 + } + } + ], "image": "/classes/test-1/test_pattern.png", "actions": [ { @@ -21,14 +31,24 @@ ] }, { - "item_id": "2", - "text": "Does the sun undertake to do the work of the rain, or Aesculpius the work of the Fruit-bearer(the earth)? And how is it with respect to each of the stars, are they not different and yet they work together to the same end?", + "lesson_id": "google-prompt-rsti", + "default_order": 2, + "title": "Review: Revisit Separate Try different phrasing Introduce constraints", + "gated": true, + "gates": [ + { + "type": "completion", + "payload": { + "speed": 7 + } + } + ], "image": "/classes/test-1/triangle-mojo.png", "actions": [ { "type": "flash", "payload": { - "speed": 10 + "speed": 8 } } ]