From df5e8f8e3cd0339c403d80c07c3302291b53ed48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Wed, 19 Aug 2026 03:17:00 +0800 Subject: [PATCH] feat: admit signed education workbench module --- .../contracts/education-workspace.json | 265 +++ .../module-donor-admission-registry.json | 14 +- .../contracts/numbered-ipc-registry.json | 225 +- ...ICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod | 33 + ...L-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig | 1 + .../unified-number-coordinate-tree.json | 256 ++- .../education-workbench-admission.test.mjs | 53 + .../unified-number-coordinate-tree.test.mjs | 4 +- .../src-tauri/Cargo.lock | 125 +- .../src-tauri/Cargo.toml | 4 + .../src-tauri/src/education_translation.rs | 1541 ++++++++++++++ .../src-tauri/src/education_workspace.rs | 1855 +++++++++++++++++ .../src-tauri/src/lib.rs | 2 + .../src-tauri/src/module_package_runtime.rs | 33 +- .../src-tauri/src/number_coordinate_tree.rs | 2 +- .../src-tauri/src/numbered_ipc.rs | 2 +- .../src-tauri/src/numbered_ipc_dispatch.rs | 61 + .../hololake-native-desktop/src/main.tsx | 46 +- .../education-adaptive-rendering.ts | 81 + .../education-workspace/education-data.ts | 75 + .../education-document-engine.tsx | 7 + .../src/modules/education-workspace/index.tsx | 169 ++ .../modules/education-workspace/styles.css | 8 + .../src/modules/numbered-ipc.ts | 144 ++ 24 files changed, 4986 insertions(+), 20 deletions(-) create mode 100644 product-source/hololake-native-desktop/contracts/education-workspace.json create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/education_translation.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/education_workspace.rs create mode 100644 product-source/hololake-native-desktop/src/modules/education-workspace/education-adaptive-rendering.ts create mode 100644 product-source/hololake-native-desktop/src/modules/education-workspace/education-data.ts create mode 100644 product-source/hololake-native-desktop/src/modules/education-workspace/education-document-engine.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/education-workspace/index.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/education-workspace/styles.css diff --git a/product-source/hololake-native-desktop/contracts/education-workspace.json b/product-source/hololake-native-desktop/contracts/education-workspace.json new file mode 100644 index 000000000..1b58970d3 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/education-workspace.json @@ -0,0 +1,265 @@ +{ + "schema": "hololake.education-workspace/v1", + "record_id": "HLP-EDUCATION-WORKSPACE-001", + "state": "ADMITTED_INSTALLED_RUNTIME_ACCEPTED", + "domain_entry": "BRANCH_DOMAIN", + "industry": "EDUCATION", + "channel_id": "GH-EDU-INIT-001", + "package": { + "package_key": "hololake.official.education-workbench", + "official_module_number": "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001", + "registration_state": "OFFICIAL_NUMBER_ACCEPTED", + "current_mount": "ACTIVE_AFTER_SIGNED_INSTALL_MOUNT_AND_SELF_TEST", + "future_distribution": "HOLOLAKE_OFFICIAL_MODULE_MARKETPLACE" + }, + "foundation_dependency": { + "module_id": "hololake.builtin.channel-workbench", + "state": "BUILT_IN_FOUNDATION", + "engines": ["LEXICAL_0_49", "FORTUNE_SHEET_1_0_4"], + "rule": "EDUCATION_MODULE_CONSUMES_FOUNDATION_WITHOUT_DUPLICATING_OFFICE_ENGINES" + }, + "native_storage": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "namespace": "education-workspace-v1", + "engine": "SQLITE", + "authenticated_account_required": true, + "cross_account_projection_allowed": false, + "public_catalog_contains_private_data": false, + "restart_readback_required": true + }, + "document_module": { + "module_id": "EDU-DOCUMENT", + "state": "DEVELOPMENT_FEATURE_MOUNTED", + "engine": "CHANNEL_WORKBENCH_LEXICAL_0_49_0_WITH_EDUCATION_TEMPLATES_AND_HOLOLAKE_MARKDOWN_STATE", + "capabilities": [ + "LIST", + "CREATE", + "CREATE_COPY", + "READ", + "EDIT_TITLE_AND_BODY", + "EXPLICIT_READ_AND_EDIT_MODES", + "HUMAN_SETTINGS_MENU", + "BASIC_FORMATTING_TOOLBAR", + "RICH_TEXT_COMMAND_ENGINE", + "UNDO_AND_REDO_HISTORY", + "MARKDOWN_SHORTCUTS_AND_BIDIRECTIONAL_STATE_TRANSLATION", + "TITLE_DIRECTORY_FILTER", + "FORMAL_TEACHING_TEMPLATES", + "NATIVE_READING_CANVAS", + "EXPORT_MARKDOWN_AND_HTML", + "SAVE_WITH_EXPECTED_REVISION", + "RECOVERABLE_ARCHIVE" + ] + }, + "table_module": { + "module_id": "EDU-TABLE", + "state": "DEVELOPMENT_FEATURE_MOUNTED", + "engine": "CHANNEL_WORKBENCH_FORTUNE_SHEET_1_0_4_FOR_REAL_CELLS_AND_FORMULAS_PLUS_TANSTACK_8_21_3_FOR_EDUCATION_VIEWS", + "capabilities": [ + "LIST", + "CREATE", + "CREATE_COPY", + "READ", + "EDIT_TITLE", + "HUMAN_SETTINGS_MENU", + "ADD_AND_REMOVE_COLUMNS", + "ADD_AND_REMOVE_ROWS", + "EDIT_CELLS", + "TITLE_DIRECTORY_FILTER", + "CELL_CONTENT_FILTER", + "COLUMN_SORT_ASCENDING_DESCENDING_OR_SOURCE_ORDER", + "HEADLESS_FILTER_SORT_GROUP_AND_AGGREGATE_ROW_MODELS", + "BOUNDED_PAGINATION_ROW_MODEL_FOR_LARGE_EDITABLE_TABLES", + "GRID_AND_EDITABLE_CARD_VIEWS", + "GROUPED_CLASSIFICATION_BOARD_FROM_SELECTED_FIELD", + "LIVE_DATA_OVERVIEW_WITH_SELECTED_GROUP_AND_MEASURE", + "HEADLESS_TABLE_DATA_DETACHED_OUTSIDE_TABLE_SURFACE", + "MULTI_ROW_MULTI_COLUMN_TABULAR_PASTE", + "EXTERNAL_SPREADSHEET_IMPORT_WITH_PREWRITE_PROFILE", + "XLSX_CSV_TSV_AND_NATIVE_EXPORT", + "SAVE_WITH_EXPECTED_REVISION", + "RECOVERABLE_ARCHIVE" + ], + "limits": { + "maximum_columns": 30, + "maximum_rows": 1000, + "maximum_cell_bytes": 10000 + }, + "sensitive_field_visibility": { + "local_default": "HIDDEN_UNTIL_HUMAN_REVEALS", + "human_can_hide": true, + "human_can_show_again": true, + "external_model_transfer_authority": "SEPARATE_EXPLICIT_PER_FILE_CONSENT" + } + }, + "content_translation_layer": { + "service_id": "EDU-CONTENT-TRANSLATOR", + "state": "DETERMINISTIC_RUNTIME_READY_MODEL_ASSIST_SLOT_RESERVED", + "profile_schema": "hololake.content-profile/v1", + "import_adapter": "EDU-TABLE-IMPORT-ADAPTER/v1", + "export_adapter": "EDU-TABLE-EXPORT-ADAPTER/v1", + "order": [ + "FILE_CONTAINER_SNIFF", + "PAGE_AND_STRUCTURE_INVENTORY", + "BOUNDED_LOCAL_RECOGNITION", + "EXPLAINABLE_SEMANTIC_FOCUS_INFERENCE", + "UNASSIGNED_CHANNEL_STAGING", + "HUMAN_INDUSTRY_ROUTE_DECISION", + "NATIVE_TRANSLATION", + "ATOMIC_MODULE_REGISTRATION_AFTER_CONFIRMATION", + "HUMAN_RECEIPT" + ], + "import_assignment": { + "default_scope": "UNASSIGNED", + "education_directory_before_human_confirmation": false, + "existing_legacy_imports_reclassified_on_migration": true, + "source_file_mutated": false + }, + "supported_imports": ["XLSX", "XLS", "XLSM", "XLSB", "ODS", "CSV", "TSV", "HOLOLAKE_NATIVE"], + "supported_exports": ["XLSX", "CSV", "TSV", "HOLOLAKE_NATIVE"], + "local_import_without_model_api": "ENABLED_FOR_ALL_SUPPORTED_FORMATS", + "semantic_focus": { + "current_mode": "DETERMINISTIC_BASELINE_ONLY", + "signals": ["SOURCE_AND_PAGE_TITLE", "COLUMN_SEMANTICS", "NUMERIC_COVERAGE"], + "low_confidence_behavior": "ASK_HUMAN_TO_SELECT_PRIMARY_MEASURE", + "model_dynamic_judgment": "RESERVED_FOR_MODEL_API_AND_AGENT_STAGE" + }, + "adaptive_rendering": { + "current_mode": "DETERMINISTIC_BASELINE_WITH_EXPLAINABLE_DEGRADATION", + "editable_cell_budget": 240, + "baseline_inputs": [ + "ROW_COUNT", + "COLUMN_COUNT", + "FIELD_TYPES", + "NUMERIC_COVERAGE", + "GROUP_CARDINALITY", + "FOCUS_CONFIDENCE" + ], + "degradation_order": [ + "LARGE_EDITABLE_GRID_TO_DYNAMIC_PAGINATION", + "NO_RELIABLE_MEASURE_TO_COUNT_SUMMARY", + "AMBIGUOUS_FOCUS_TO_HUMAN_SELECTION", + "UNSUPPORTED_STRUCTURE_TO_HUMAN_ASSISTANCE_RECEIPT" + ], + "local_baseline_always_available": true, + "model_adjustment": { + "api_slot": "HOLOLAKE_MODEL_RECOGNITION_API/v1", + "state": "RESERVED_FOR_MODEL_API_AND_AGENT_STAGE", + "proposal_fields": [ + "PRIMARY_FOCUS", + "GROUP_FIELD", + "MEASURE_FIELD", + "VIEW_COMPOSITION", + "PAGE_DENSITY" + ], + "proposal_must_pass_local_structure_validation": true, + "proposal_must_obey_local_resource_limits": true, + "human_confirmation_before_rule_activation": true, + "model_can_mutate_source_data": false, + "failure_behavior": "RETURN_TO_LOCAL_BASELINE_WITH_RECEIPT" + } + }, + "unknown_input_behavior": "RETURN_HUMAN_ASSISTANCE_RECEIPT_WITHOUT_MODULE_WRITE_OR_EXTERNAL_MODEL_TRANSFER", + "model_assist": { + "api_slot": "HOLOLAKE_MODEL_RECOGNITION_API/v1", + "current_state": "NOT_CONFIGURED", + "provider": "USER_SELECTED", + "secret_storage": "OPERATING_SYSTEM_SECRET_STORE_REQUIRED", + "file_transfer_default": "DENY_UNTIL_EXPLICIT_PER_FILE_CONSENT", + "rule_update_flow": ["MODEL_CANDIDATE", "LOCAL_VALIDATION", "HUMAN_CONFIRMATION", "VERSIONED_INSTALL"], + "learning_scopes": ["PRIVATE_ONLY", "SHARE_ANONYMIZED_RULE"] + }, + "source_file_mutation_allowed": false, + "silent_truncation_allowed": false + }, + "data_cleanup_module": { + "module_id": "EDU-DATA", + "state": "DEVELOPMENT_FEATURE_MOUNTED", + "source": "CURRENT_AUTHENTICATED_ACCOUNTS_EDUCATION_TABLES", + "capabilities": [ + "SELECT_ACCOUNT_TABLE", + "TRIM_CELL_BOUNDARY_WHITESPACE", + "REMOVE_FULLY_EMPTY_ROWS", + "REMOVE_EXACT_DUPLICATE_ROWS_KEEP_FIRST", + "PREVIEW_BEFORE_WRITE", + "FULL_TABLE_ANALYSIS_WITH_BOUNDED_CHANGE_PREVIEW", + "ACCESSIBILITY_SAFE_NON_TABLE_CHANGE_LIST", + "HUMAN_CONFIRMATION_BEFORE_SAVE", + "SAVE_WITH_EXPECTED_REVISION" + ], + "preview_change_limit": 12, + "preview_cell_value_character_limit": 120, + "full_table_analysis_is_limited_by_preview": false, + "automatic_destructive_cleanup_allowed": false, + "cross_account_data_allowed": false + }, + "automation_module": { + "module_id": "EDU-AUTOMATION", + "state": "DEVELOPMENT_FEATURE_MOUNTED", + "source": "CURRENT_AUTHENTICATED_ACCOUNTS_EDUCATION_TABLES", + "capabilities": [ + "CREATE_AND_SAVE_RULE", + "EQUALS_CONTAINS_EMPTY_AND_NONEMPTY_CONDITIONS", + "SET_TARGET_CELL_VALUE", + "PREVIEW_MATCHED_ROWS_AND_CHANGED_CELLS", + "HUMAN_CONFIRMATION_BEFORE_EXECUTION", + "RULE_AND_TABLE_REVISION_LOCK", + "SIGNED_PREVIEW_TOKEN", + "ATOMIC_TABLE_WRITE_AND_EXECUTION_RECEIPT", + "RECOVERABLE_RULE_ARCHIVE" + ], + "background_or_scheduled_execution_allowed": false, + "cross_account_data_allowed": false, + "model_execution_authority_granted": false + }, + "composition_module": { + "module_id": "EDU-COMPOSITION", + "state": "DEVELOPMENT_FEATURE_MOUNTED", + "source": "CURRENT_AUTHENTICATED_ACCOUNTS_REAL_KNOWLEDGE_CATALOG", + "capabilities": [ + "NATIVE_TYPED_OBJECT", + "REGISTERED_MODULE_EXECUTION_GRAPH", + "SELECT_DIMENSION_AND_MEASURE", + "DASHBOARD_PROJECTION", + "COMPARISON_PROJECTION", + "VERTICAL_BAR_PROJECTION", + "CLASSIFICATION_PROJECTION", + "TABLE_PROJECTION", + "ONE_EXECUTION_RESULT_MULTIPLE_HUMAN_VIEWS" + ], + "read_only": true, + "hardcoded_sample_data_used": false, + "model_execution_authority_granted": false + }, + "remaining_slots": [], + "authority": { + "browser_local_storage_allowed": false, + "webview_direct_file_access_allowed": false, + "model_execution_authority_granted": false, + "external_application_embedded": false + }, + "current_acceptance": { + "state": "PASS", + "runtime_module_number": "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001", + "numbered_ipc_module": "HLP-NIPC-MOD-0024", + "numbered_operations": "HLP-NIPC-OP-0085..0102", + "signed_package_sha256": "a9f6fb7dd9c4aaf910a5d20d0a1d72dea6ab921cf36dd50d46dd88cde3a09e03", + "signed_app_binary_sha256": "028592b137fe4f19a642e1301d6fb500e2a4fde50ddb616e229de8a48090803c", + "developer_id_team": "825A9L3G7Q", + "developer_id_cdhash": "4c88261b4b573d08edcbba8d67d8afe27ad82a3e", + "runtime_receipts": { + "install": "68f296a11be19c384cb67af9f529a03cb8389bd256c153c5905451fbc0011a24", + "mount": "ddb6a51d47f6a58a72a7fc748eff71687cbd04be5dde33dbce590a7abba6af5f", + "self_test_pass": "80d59b7c9c2f14005fd8a24748a25c2be0d55198fdcb621f528267ae6dccc12a" + }, + "restart_readback": { + "module_state": "ACTIVE", + "document": "冰朔教育迁移验收 · revision 2", + "table": "冰朔教育迁移验收表 · 1 row · revision 4", + "automation": "验收状态自动化 · preview 1 row / 1 cell · APPLIED revision 3 to 4" + }, + "existing_user_data_deleted_or_overwritten": false, + "apple_notarization_scope": "FINAL_RELEASE_CANDIDATE_ONLY_NOT_THIS_DEBUG_ACCEPTANCE_BUNDLE" + }, + "open_source_donor_assessment": "contracts/education-open-source-donor-assessment.json" +} diff --git a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json index 0f2e3ea10..08644f2ed 100644 --- a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json +++ b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json @@ -1,7 +1,7 @@ { "schema": "hololake.module-donor-admission-registry/v1", "record_id": "HLP-MODULE-DONOR-ADMISSION-001", - "state": "THREE_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", + "state": "FOUR_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", "root_rule": { "official_base": "HOLOLAKE_0.5.0_NUMBERED_IPC_ROOT", "repair_old_application_in_place": false, @@ -114,15 +114,17 @@ "admission_order": 4, "candidate_number": "HLP-DONOR-CAND-0004", "name": "education_workbench", - "state": "QUARANTINED_PENDING_ADMISSION", + "state": "ADMITTED_INSTALLED_RUNTIME_ACCEPTED", "paths": [ "contracts/education-workspace.json", - "src/modules/education-adaptive-rendering.ts", - "src/modules/education-data.ts", - "src/modules/education-document-engine.tsx", + "src/modules/education-workspace", "src-tauri/src/education_translation.rs", "src-tauri/src/education_workspace.rs" - ] + ], + "runtime_module_number": "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001", + "numbered_ipc_module": "HLP-NIPC-MOD-0024", + "acceptance_evidence": "contracts/education-workspace.json#current_acceptance", + "boundary": "CURRENT_ACCOUNT_LOCAL; IMPORTS_DEFAULT_UNASSIGNED; CLEANUP_AND_AUTOMATION_REQUIRE_EXPLICIT_HUMAN_CONFIRMATION; MODEL_FILE_TRANSFER_DEFAULT_DENY" }, { "admission_order": 5, diff --git a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json index 08cc2ddce..114749a4a 100644 --- a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json +++ b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json @@ -66,7 +66,10 @@ "get_native_composition_module_registry", "get_channel_workbench_snapshot", "get_persona_channel_body", - "get_channel_growth_snapshot" + "get_channel_growth_snapshot", + "get_education_workspace_snapshot", + "import_education_tables_from_dialog", + "get_education_recognition_capability" ], "input_wrapper_aliases": [ "confirm_hololake_update_install", @@ -113,7 +116,22 @@ "accept_persona_language_contract", "append_persona_language", "record_channel_growth_event", - "update_channel_growth_sharing" + "update_channel_growth_sharing", + "create_education_document", + "read_education_document", + "save_education_document", + "archive_education_document", + "create_education_table", + "read_education_table", + "save_education_table", + "archive_education_table", + "assign_imported_table_to_education", + "export_education_table_to_dialog", + "create_education_automation_rule", + "save_education_automation_rule", + "archive_education_automation_rule", + "preview_education_automation_rule", + "execute_education_automation_rule" ], "direct_field_aliases": { "perform_code_repo_login": [ @@ -275,6 +293,11 @@ "module_number": "HLP-NIPC-MOD-0023", "target_number": "HLP-NIPC-TGT-0023", "internal_name": "persona_channel_body" + }, + { + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "internal_name": "education_workspace" } ], "operations": [ @@ -1201,6 +1224,204 @@ "admission": "VERIFIED_HUMAN_ROUTE", "effect": "STATE_CHANGE", "payload_schema": "hololake.numbered-ipc.payload/update_channel_growth_sharing/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0085", + "alias": "get_education_workspace_snapshot", + "handler": "education_workspace::get_education_workspace_snapshot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_education_workspace_snapshot/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0086", + "alias": "create_education_document", + "handler": "education_workspace::create_education_document", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_education_document/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0087", + "alias": "read_education_document", + "handler": "education_workspace::read_education_document", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/read_education_document/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0088", + "alias": "save_education_document", + "handler": "education_workspace::save_education_document", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/save_education_document/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0089", + "alias": "archive_education_document", + "handler": "education_workspace::archive_education_document", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/archive_education_document/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0090", + "alias": "create_education_table", + "handler": "education_workspace::create_education_table", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_education_table/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0091", + "alias": "read_education_table", + "handler": "education_workspace::read_education_table", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/read_education_table/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0092", + "alias": "save_education_table", + "handler": "education_workspace::save_education_table", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/save_education_table/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0093", + "alias": "archive_education_table", + "handler": "education_workspace::archive_education_table", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/archive_education_table/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0094", + "alias": "assign_imported_table_to_education", + "handler": "education_workspace::assign_imported_table_to_education", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/assign_imported_table_to_education/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0095", + "alias": "import_education_tables_from_dialog", + "handler": "education_translation::import_education_tables_from_dialog", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/import_education_tables_from_dialog/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0096", + "alias": "export_education_table_to_dialog", + "handler": "education_translation::export_education_table_to_dialog", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/export_education_table_to_dialog/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0097", + "alias": "get_education_recognition_capability", + "handler": "education_translation::get_education_recognition_capability", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_education_recognition_capability/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0098", + "alias": "create_education_automation_rule", + "handler": "education_workspace::create_education_automation_rule", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/create_education_automation_rule/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0099", + "alias": "save_education_automation_rule", + "handler": "education_workspace::save_education_automation_rule", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/save_education_automation_rule/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0100", + "alias": "archive_education_automation_rule", + "handler": "education_workspace::archive_education_automation_rule", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/archive_education_automation_rule/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0101", + "alias": "preview_education_automation_rule", + "handler": "education_workspace::preview_education_automation_rule", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/preview_education_automation_rule/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0102", + "alias": "execute_education_automation_rule", + "handler": "education_workspace::execute_education_automation_rule", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0024", + "target_number": "HLP-NIPC-TGT-0024", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/execute_education_automation_rule/v1" } ] } diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod new file mode 100644 index 000000000..ed4c14ba8 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod @@ -0,0 +1,33 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001", + "registrationClass": "OFFICIAL_LIGHTHOUSE", + "displayName": "教育行业工作台", + "version": "0.1.0", + "minimumHostVersion": "0.5.0", + "adapter": "education-workbench-v1", + "contentDigest": "fd80c9957865a58cf62679528f110ae355b1385ad1dd988e31217ea3da66e65b", + "permissions": ["EDUCATION_WORKSPACE_READ", "EDUCATION_DOCUMENT_WRITE", "EDUCATION_TABLE_WRITE", "EDUCATION_IMPORT_READ_FILE", "EDUCATION_EXPORT_WRITE_FILE", "EDUCATION_IMPORT_ASSIGNMENT_WRITE", "EDUCATION_AUTOMATION_WRITE", "EDUCATION_AUTOMATION_EXECUTE"], + "userDataSchema": "hololake.module-data/education-workbench/v1", + "selfTest": { + "kind": "DECLARATIVE_SCHEMA_V1", + "expectedContentDigest": "fd80c9957865a58cf62679528f110ae355b1385ad1dd988e31217ea3da66e65b" + } + }, + "payload": { + "entry": "education-workspace", + "adapterConfig": { + "dataBoundary": "CURRENT_AUTHENTICATED_ACCOUNT_LOCAL", + "foundationModule": "HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001", + "documentEngine": "LEXICAL_0_49", + "spreadsheetEngine": "FORTUNE_SHEET_1_0_4", + "importDefaultScope": "UNASSIGNED", + "automationExecution": "PREVIEW_TOKEN_AND_HUMAN_CONFIRMATION", + "modelFileTransferDefault": "DENY", + "maximumColumns": 30, + "maximumRows": 1000, + "persistent": true + } + } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..11d79a513 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRTFEa084d1l6MnB2OFVvVy9abW90S3RFMEVOdmIvWW1hbkllWmw4NHBJaDVNTGxpZHV4M0tVNUNUOExmdHAwSGw4V0NPQ1RPUjlRU0h0Tm50clFlekFJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDc5ODQzCWZpbGU6SExQLU1PRC1PRkZJQ0lBTC1FRFVDQVRJT04tV09SS0JFTkNILTAwMDEtMC4xLjAuZ2htb2QKVXZzVklwQk5KR3Q3bkRZaXBYV1ArOGx6b3BKSWVyUkpvK1dyTXdpWnBhMXNqVnJKcU5vZ1BTdzlFWjlXRTJ1RlFhMXAwMjhXcFBEQnQxRzV4SFVuQkE9PQo= \ No newline at end of file diff --git a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json index 84b2d3a96..981cd6075 100644 --- a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json +++ b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json @@ -54,7 +54,7 @@ }, { "recordId": "HLP-NUMBERED-IPC-ROOT-001", - "sha256": "bd8446d66e03e220948f46c4ea9d0fbe65c517cdac500d548f2b643fb24649d6" + "sha256": "be7f2a746a78284d4c763d9da62a381bf0c9430c56a923398e1948d2a0ae1fc0" }, { "recordId": "HLP-NBROKER-ROOT-001", @@ -69,7 +69,7 @@ "everyAcceptedCallHasEvidenceClass": true, "mismatchedCoordinate": "FAIL_CLOSED" }, - "routeCount": 106, + "routeCount": 124, "routes": [ { "transport": "DIRECT_LOCAL_NUMBERED_BROKER", @@ -1163,6 +1163,258 @@ "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0084/HLP-NIPC-TGT-0023" }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0085", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "get_education_workspace_snapshot", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0085/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0086", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "create_education_document", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0086/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0087", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "read_education_document", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0087/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0088", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "save_education_document", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0088/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0089", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "archive_education_document", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0089/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0090", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "create_education_table", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0090/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0091", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "read_education_table", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0091/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0092", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "save_education_table", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0092/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0093", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "archive_education_table", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0093/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0094", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "assign_imported_table_to_education", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0094/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0095", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "import_education_tables_from_dialog", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0095/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0096", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "export_education_table_to_dialog", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0096/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0097", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "get_education_recognition_capability", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0097/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0098", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "create_education_automation_rule", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0098/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0099", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "save_education_automation_rule", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0099/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0100", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "archive_education_automation_rule", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0100/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0101", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "preview_education_automation_rule", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0101/HLP-NIPC-TGT-0024" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0102", + "targetNumber": "HLP-NIPC-TGT-0024", + "alias": "execute_education_automation_rule", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0102/HLP-NIPC-TGT-0024" + }, { "transport": "TAURI_WEBVIEW_NUMBERED_IPC", "protocolVersion": "HLP-NIPC-v1", diff --git a/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs b/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs new file mode 100644 index 000000000..644f94f55 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/education-workbench-admission.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/education-workspace.json')) +const registry = JSON.parse(read('contracts/numbered-ipc-registry.json')) +const modulePackage = JSON.parse(read('fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod')) +const workspaceRust = read('src-tauri/src/education_workspace.rs') +const translationRust = read('src-tauri/src/education_translation.rs') +const frontend = [ + read('src/modules/education-workspace/index.tsx'), + read('src/modules/education-workspace/education-data.ts'), + read('src/modules/education-workspace/education-adaptive-rendering.ts'), +].join('\n') + +test('education is an official signed declarative adapter over the shared office foundation', () => { + assert.equal(contract.package.official_module_number, 'HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001') + assert.equal(contract.foundation_dependency.module_id, 'hololake.builtin.channel-workbench') + assert.equal(modulePackage.manifest.registrationClass, 'OFFICIAL_LIGHTHOUSE') + assert.equal(modulePackage.manifest.adapter, 'education-workbench-v1') + assert.equal(modulePackage.payload.adapterConfig.foundationModule, 'HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001') + assert.equal(modulePackage.payload.adapterConfig.importDefaultScope, 'UNASSIGNED') + assert.equal(modulePackage.payload.adapterConfig.modelFileTransferDefault, 'DENY') + assert.equal(modulePackage.manifest.permissions.length, 8) +}) + +test('all education effects cross the exact numbered adapter routes', () => { + const routes = registry.operations.filter((route) => route.module_number === 'HLP-NIPC-MOD-0024') + assert.deepEqual(routes.map((route) => route.operation_number), Array.from({ length: 18 }, (_, index) => `HLP-NIPC-OP-${String(index + 85).padStart(4, '0')}`)) + assert.ok(routes.every((route) => route.target_number === 'HLP-NIPC-TGT-0024')) + assert.ok(routes.every((route) => route.admission === 'VERIFIED_HUMAN_ROUTE')) + assert.doesNotMatch(frontend, /from ['"]@tauri-apps\/api\/core['"]/) + assert.doesNotMatch(workspaceRust, /#\[tauri::command\]/) + assert.doesNotMatch(translationRust, /#\[tauri::command\]/) + assert.match(workspaceRust, /require_active_module_adapter/) +}) + +test('legacy silent corruption and automatic destructive behavior are rejected', () => { + assert.doesNotMatch(workspaceRust, /serde_json::from_str[^\n]+unwrap_or_default/) + assert.match(workspaceRust, /deny_unknown_fields/) + assert.match(frontend, /生成只读预览/) + assert.match(frontend, /我确认,写入清理结果/) + assert.match(frontend, /生成只读预览令牌/) + assert.match(frontend, /我确认,按此令牌执行/) + assert.match(frontend, /确认归入教育频道/) +}) + +test('education UI is independently lazy-loaded and reuses, rather than duplicates, office engines', () => { + assert.match(read('src/main.tsx'), /lazy\(\(\) => import\('\.\/modules\/education-workspace'\)/) + assert.match(read('src/modules/education-workspace/education-document-engine.tsx'), /channel-workbench\/document-engine/) + assert.match(read('src/modules/education-workspace/index.tsx'), /channel-workbench\/spreadsheet-engine/) +}) diff --git a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs index b77594270..79d84cd5e 100644 --- a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs +++ b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs @@ -7,8 +7,8 @@ test('identity, webview and direct broker numbers compile into one unique eviden const generated = JSON.parse(readFileSync(new URL('../generated/unified-number-coordinate-tree.json', import.meta.url), 'utf8')) assert.deepEqual(generated, compileUnifiedNumberTree()) assert.equal(generated.recordId, 'HLP-UNIFIED-NUMBER-TREE-001') - assert.equal(generated.routeCount, 106) - assert.equal(new Set(generated.routes.map((route) => route.path)).size, 106) + assert.equal(generated.routeCount, 124) + assert.equal(new Set(generated.routes.map((route) => route.path)).size, 124) assert.equal(generated.invariants.everyPhysicalCallHasNumberedRoute, true) assert.equal(generated.invariants.everyAcceptedCallHasEvidenceClass, true) assert.ok(generated.routes.every((route) => route.admission && route.evidence)) diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index 85ae99eb7..818ac7dc6 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -268,6 +268,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calamine" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1" +dependencies = [ + "byteorder", + "chrono", + "codepage", + "encoding_rs", + "log", + "quick-xml 0.31.0", + "serde", + "zip 2.4.2", +] + [[package]] name = "camino" version = "1.2.5" @@ -393,6 +409,15 @@ dependencies = [ "cc", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "combine" version = "4.6.7" @@ -570,6 +595,27 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.8.0" @@ -890,6 +936,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1495,7 +1550,10 @@ name = "hololake-native-desktop" version = "0.5.0" dependencies = [ "base64 0.22.1", + "calamine", + "csv", "dirs", + "encoding_rs", "fs2", "futures-util", "interprocess", @@ -1503,6 +1561,7 @@ dependencies = [ "reqwest", "ring", "rusqlite", + "rust_xlsxwriter", "serde", "serde_json", "tauri", @@ -2834,7 +2893,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -2981,6 +3040,16 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -3351,6 +3420,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust_xlsxwriter" +version = "0.64.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f47f5318c1e512e57c07781559367577b1eb9618325cf1574cd30d38b112c5" +dependencies = [ + "lazy_static", + "regex", + "zip 0.6.6", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4311,7 +4391,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5847,6 +5927,35 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" @@ -5864,3 +5973,15 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index d047dd6db..91df30f3c 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -21,6 +21,9 @@ interprocess = "2.4.2" ring = "0.17" rusqlite = { version = "0.31", features = ["bundled"] } base64 = "0.22" +calamine = { version = "=0.26.1", features = ["dates"] } +csv = "=1.3.0" +encoding_rs = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "=2.10.2", features = ["devtools"] } @@ -31,6 +34,7 @@ tauri-plugin-dialog = "=2.7.2" uuid = { version = "1", features = ["v4"] } url = "2" reqwest = { version = "0.13.2", default-features = false, features = ["cookies", "form", "json", "rustls", "stream"] } +rust_xlsxwriter = "=0.64.2" tokio = { version = "1", features = ["time"] } futures-util = "0.3" minisign-verify = "0.2.5" diff --git a/product-source/hololake-native-desktop/src-tauri/src/education_translation.rs b/product-source/hololake-native-desktop/src-tauri/src/education_translation.rs new file mode 100644 index 000000000..5a183b89c --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/education_translation.rs @@ -0,0 +1,1541 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 教育表格的双向动态转译层。 +//! +//! 外部文件只作为只读输入。解析完成后立即转换为 HoloLake 原生列/行结构并进入 +//! 当前登录账号的 SQLite 空间;WebView 不接触任意本机路径。导出则从原生结构生成 +//! 人类选择的交换格式,不把 Office 文档模型带进运行内核。 + +use crate::education_workspace::{ + education_workspace_database, import_table_batch_at, validate_table_data, + EducationImportRegistration, EducationTableColumn, EducationTableRow, ImportedEducationTable, +}; +use calamine::{open_workbook_auto, Data, Reader}; +use encoding_rs::GBK; +use ring::digest::{digest, SHA256}; +use rust_xlsxwriter::{Color, Format, Workbook}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; + +const TRANSLATOR_SCHEMA: &str = "hololake.education-table-translator/v1"; +const NATIVE_TABLE_SCHEMA: &str = "hololake.education-table/v1"; +const IMPORT_ADAPTER: &str = "EDU-TABLE-IMPORT-ADAPTER/v1"; +const EXPORT_ADAPTER: &str = "EDU-TABLE-EXPORT-ADAPTER/v1"; +const MAX_IMPORT_FILE_BYTES: u64 = 25 * 1024 * 1024; +const MAX_TABLE_COLUMNS: usize = 30; +const MAX_TABLE_ROWS: usize = 1_000; +const MAX_CELL_BYTES: usize = 10_000; +const MAX_TITLE_BYTES: usize = 300; +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001"; +const ADAPTER: &str = "education-workbench-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTableImportItem { + pub table_id: String, + pub title: String, + pub sheet_name: String, + pub column_count: usize, + pub row_count: usize, + pub leading_rows_ignored: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTableImportReceipt { + pub schema: &'static str, + pub state: &'static str, + pub adapter_id: &'static str, + pub import_id: String, + pub source_format: String, + pub source_filename: String, + pub source_sha256: String, + pub source_bytes: u64, + pub native_schema: &'static str, + pub content_profile: EducationContentProfile, + pub imported_tables: Vec, + pub imported_at_unix_ms: u128, + pub source_preserved_read_only: bool, + pub truncated: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationContentProfile { + pub schema: &'static str, + pub state: &'static str, + pub detected_family: String, + pub recognition_mode: String, + pub container_format: String, + pub extension_matches_container: bool, + pub page_kind: String, + pub page_count: usize, + pub non_empty_page_count: usize, + pub total_data_rows: usize, + pub total_columns: usize, + pub pages: Vec, + pub routing: Vec, + pub unresolved_signals: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationImportOutcome { + pub state: String, + pub import_receipt: Option, + pub assistance_receipt: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationRecognitionAssistanceReceipt { + pub schema: &'static str, + pub state: String, + pub request_id: String, + pub title: String, + pub message: String, + pub source_filename: String, + pub detected_container: String, + pub reason_code: String, + pub model_assistance_eligible: bool, + pub model_api_state: &'static str, + pub model_api_slot: &'static str, + pub requires_explicit_file_consent: bool, + pub source_preserved_read_only: bool, + pub available_learning_scopes: Vec<&'static str>, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationRecognitionCapability { + pub schema: &'static str, + pub state: &'static str, + pub deterministic_adapter_ids: Vec<&'static str>, + pub model_api_slot: &'static str, + pub model_api_state: &'static str, + pub provider_binding: &'static str, + pub secret_storage_requirement: &'static str, + pub file_transfer_default: &'static str, + pub rule_update_flow: Vec<&'static str>, + pub learning_scopes: Vec<&'static str>, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationContentPageProfile { + pub page_name: String, + pub state: String, + pub used_rows: usize, + pub used_columns: usize, + pub header_row_index: Option, + pub header_confidence: String, + pub data_rows: usize, + pub text_cells: usize, + pub numeric_cells: usize, + pub boolean_cells: usize, + pub date_cells: usize, + pub formula_cells: usize, + pub structure_kind: String, + pub focus_state: String, + pub focus_title: String, + pub primary_measure_column: Option, + pub secondary_measure_columns: Vec, + pub focus_confidence: String, + pub focus_reasons: Vec, +} + +#[derive(Clone, Debug)] +struct EducationSemanticFocus { + state: String, + title: String, + primary_measure_column: Option, + secondary_measure_columns: Vec, + confidence: String, + reasons: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationContentRoute { + pub page_name: String, + pub source_structure: String, + pub native_kind: String, + pub target_module: String, + pub decision: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExportEducationTableInput { + pub table: EducationTableExportDraft, + pub format: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EducationTableExportDraft { + pub table_id: String, + pub title: String, + pub columns: Vec, + pub rows: Vec, + pub revision: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTableExportReceipt { + pub schema: &'static str, + pub state: &'static str, + pub adapter_id: &'static str, + pub table_id: String, + pub table_revision: i64, + pub target_format: String, + pub target_filename: String, + pub bytes: u64, + pub row_count: usize, + pub column_count: usize, + pub exported_at_unix_ms: u128, +} + +#[derive(Clone, Debug)] +struct ParsedSheet { + sheet_name: String, + leading_rows_ignored: usize, + table: ImportedEducationTable, +} + +#[derive(Clone, Debug)] +struct ParseOutcome { + source_format: String, + content_profile: EducationContentProfile, + parsed_sheets: Vec, +} + +#[derive(Clone, Copy, Debug, Default)] +struct CellCounts { + text: usize, + numeric: usize, + boolean: usize, + date: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NativeEducationTableFile { + schema: String, + title: String, + columns: Vec, + rows: Vec, +} + +pub async fn import_education_tables_from_dialog( + app: AppHandle, +) -> Result, String> { + require_active(&app)?; + let selected = app + .dialog() + .file() + .set_title("导入外部表格并转为 HoloLake 原生格式") + .add_filter( + "表格文件", + &["xlsx", "xls", "xlsm", "xlsb", "ods", "csv", "tsv", "json"], + ) + .blocking_pick_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let path = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_PATH_INVALID: {error}"))?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || match import_tables_from_path(&database, &path) { + Ok(receipt) => Ok(EducationImportOutcome { + state: "STAGED_UNASSIGNED".into(), + import_receipt: Some(receipt), + assistance_receipt: None, + }), + Err(error) if is_human_assistance_case(&error) => Ok(EducationImportOutcome { + state: "NEEDS_HUMAN_DECISION".into(), + import_receipt: None, + assistance_receipt: Some(assistance_receipt_for(&path, &error)), + }), + Err(error) => Err(error), + }) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_JOIN_FAILED: {error}"))? + .map(Some) +} + +pub fn get_education_recognition_capability( + app: AppHandle, +) -> Result { + require_active(&app)?; + Ok(EducationRecognitionCapability { + schema: "hololake.education-recognition-capability/v1", + state: "DETERMINISTIC_READY_MODEL_SLOT_UNBOUND", + deterministic_adapter_ids: vec![IMPORT_ADAPTER, EXPORT_ADAPTER], + model_api_slot: "HOLOLAKE_MODEL_RECOGNITION_API/v1", + model_api_state: "NOT_CONFIGURED", + provider_binding: "USER_SELECTED_PROVIDER", + secret_storage_requirement: "OPERATING_SYSTEM_SECRET_STORE", + file_transfer_default: "DENY_UNTIL_EXPLICIT_PER_FILE_CONSENT", + rule_update_flow: vec![ + "MODEL_PROPOSES_CANDIDATE", + "LOCAL_VALIDATION", + "HUMAN_CONFIRMATION", + "VERSIONED_RULE_INSTALL", + ], + learning_scopes: vec!["PRIVATE_ONLY", "SHARE_ANONYMIZED_RULE"], + }) +} + +pub async fn export_education_table_to_dialog( + app: AppHandle, + input: ExportEducationTableInput, +) -> Result, String> { + require_active(&app)?; + validate_export_draft(&input.table)?; + let format = normalized_export_format(&input.format)?; + let extension = match format.as_str() { + "XLSX" => "xlsx", + "CSV" => "csv", + "TSV" => "tsv", + "HOLOLAKE_NATIVE" => "holotable.json", + _ => return Err("HOLOLAKE_EDUCATION_EXPORT_FORMAT_UNSUPPORTED".into()), + }; + let filename = format!("{}.{}", safe_filename(&input.table.title), extension); + let selected = app + .dialog() + .file() + .set_title("导出教育表格") + .set_file_name(filename) + .add_filter(export_filter_label(&format), &[extension]) + .blocking_save_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let path = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_PATH_INVALID: {error}"))?; + tauri::async_runtime::spawn_blocking(move || export_table_to_path(&path, &format, input.table)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_JOIN_FAILED: {error}"))? + .map(Some) +} + +fn import_tables_from_path( + database: &Path, + source: &Path, +) -> Result { + let metadata = fs::metadata(source) + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_UNREADABLE: {error}"))?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_IMPORT_FILE_BYTES { + return Err("HOLOLAKE_EDUCATION_IMPORT_FILE_BOUNDS_INVALID".into()); + } + let source_filename = source + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "HOLOLAKE_EDUCATION_IMPORT_FILENAME_INVALID".to_string())? + .to_string(); + let source_bytes = fs::read(source) + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_UNREADABLE: {error}"))?; + let source_sha256 = hex_digest(&source_bytes); + let outcome = parse_source_file(source, &source_bytes)?; + if outcome.parsed_sheets.is_empty() { + return Err("HOLOLAKE_EDUCATION_IMPORT_EMPTY".into()); + } + let imported_at_unix_ms = now_ms(); + let profile_json = serde_json::to_string(&outcome.content_profile) + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_PROFILE_INVALID: {error}"))?; + let (import_id, imported) = import_table_batch_at( + database, + outcome + .parsed_sheets + .iter() + .map(|sheet| sheet.table.clone()) + .collect(), + EducationImportRegistration { + source_filename: source_filename.clone(), + source_format: outcome.source_format.clone(), + source_sha256: source_sha256.clone(), + profile_json, + imported_at_unix_ms: imported_at_unix_ms as i64, + }, + )?; + let imported_tables = outcome + .parsed_sheets + .into_iter() + .zip(imported) + .map(|(sheet, table)| EducationTableImportItem { + table_id: table.table_id, + title: table.title, + sheet_name: sheet.sheet_name, + column_count: table.columns.len(), + row_count: table.rows.len(), + leading_rows_ignored: sheet.leading_rows_ignored, + }) + .collect(); + Ok(EducationTableImportReceipt { + schema: TRANSLATOR_SCHEMA, + state: "STAGED_UNASSIGNED", + adapter_id: IMPORT_ADAPTER, + import_id, + source_format: outcome.source_format, + source_filename, + source_sha256, + source_bytes: metadata.len(), + native_schema: NATIVE_TABLE_SCHEMA, + content_profile: outcome.content_profile, + imported_tables, + imported_at_unix_ms, + source_preserved_read_only: true, + truncated: false, + }) +} + +fn parse_source_file(source: &Path, bytes: &[u8]) -> Result { + let filename = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if filename.ends_with(".holotable.json") { + let sheet = parse_native_table(bytes)?; + return Ok(outcome_from_parsed( + "HOLOLAKE_NATIVE", + "NATIVE_JSON", + true, + vec![sheet], + Vec::new(), + )); + } + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let detected_container = detect_container(bytes); + match extension.as_str() { + "csv" => parse_delimited(bytes, b',', source).map(|sheet| { + outcome_from_parsed("CSV", "DELIMITED_TEXT", true, vec![sheet], Vec::new()) + }), + "tsv" => parse_delimited(bytes, b'\t', source).map(|sheet| { + outcome_from_parsed("TSV", "DELIMITED_TEXT", true, vec![sheet], Vec::new()) + }), + "xlsx" | "xls" | "xlsm" | "xlsb" | "ods" => parse_workbook( + source, + &detected_workbook_format(&extension, &detected_container), + &detected_container, + extension_matches_container(&extension, &detected_container), + ), + _ => Err("HOLOLAKE_EDUCATION_IMPORT_FORMAT_UNSUPPORTED".into()), + } +} + +fn parse_workbook( + source: &Path, + detected_format: &str, + detected_container: &str, + extension_matches: bool, +) -> Result { + let mut workbook = open_workbook_auto(source) + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_WORKBOOK_INVALID: {error}"))?; + let stem = file_stem(source); + let sheet_names = workbook.sheet_names().to_vec(); + let mut parsed = Vec::new(); + let mut profiles = Vec::new(); + for sheet_name in sheet_names { + let range = workbook + .worksheet_range(&sheet_name) + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_SHEET_INVALID: {error}"))?; + let formula_range = workbook.worksheet_formula(&sheet_name).ok(); + let formula_cells = formula_range + .as_ref() + .map(|formulas| { + formulas + .rows() + .flat_map(|row| row.iter()) + .filter(|formula| !formula.trim().is_empty()) + .count() + }) + .unwrap_or(0); + let cell_counts = workbook_cell_counts(range.rows().flat_map(|row| row.iter())); + let matrix = range + .rows() + .map(|row| row.iter().map(cell_text).collect::>()) + .collect::>(); + if let Some((table, leading_rows_ignored)) = + matrix_to_native_table(matrix, &workbook_table_title(&stem, &sheet_name), true)? + { + profiles.push(profile_for_imported_sheet( + &sheet_name, + &table, + leading_rows_ignored, + cell_counts, + formula_cells, + )); + parsed.push(ParsedSheet { + sheet_name, + leading_rows_ignored, + table, + }); + } else { + profiles.push(EducationContentPageProfile { + page_name: sheet_name, + state: "EMPTY_SKIPPED".into(), + used_rows: 0, + used_columns: 0, + header_row_index: None, + header_confidence: "NONE".into(), + data_rows: 0, + text_cells: 0, + numeric_cells: 0, + boolean_cells: 0, + date_cells: 0, + formula_cells, + structure_kind: "EMPTY".into(), + focus_state: "NO_CONTENT".into(), + focus_title: "空页".into(), + primary_measure_column: None, + secondary_measure_columns: Vec::new(), + focus_confidence: "NONE".into(), + focus_reasons: vec!["页面没有可分析的数据".into()], + }); + } + } + Ok(outcome_from_parsed( + detected_format, + detected_container, + extension_matches, + parsed, + profiles, + )) +} + +fn parse_delimited(bytes: &[u8], delimiter: u8, source: &Path) -> Result { + let decoded = decode_delimited_text(bytes)?; + let mut reader = csv::ReaderBuilder::new() + .has_headers(false) + .flexible(true) + .delimiter(delimiter) + .from_reader(decoded.as_bytes()); + let mut matrix = Vec::new(); + for record in reader.records() { + let record = record + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_DELIMITED_INVALID: {error}"))?; + matrix.push(record.iter().map(ToString::to_string).collect()); + if matrix.len() > MAX_TABLE_ROWS + 32 { + return Err("HOLOLAKE_EDUCATION_IMPORT_ROW_LIMIT_EXCEEDED".into()); + } + } + let title = file_stem(source); + let (table, leading_rows_ignored) = matrix_to_native_table(matrix, &title, false)? + .ok_or_else(|| "HOLOLAKE_EDUCATION_IMPORT_EMPTY".to_string())?; + Ok(ParsedSheet { + sheet_name: title, + leading_rows_ignored, + table, + }) +} + +fn parse_native_table(bytes: &[u8]) -> Result { + let native: NativeEducationTableFile = serde_json::from_slice(bytes) + .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_NATIVE_INVALID: {error}"))?; + if native.schema != NATIVE_TABLE_SCHEMA { + return Err("HOLOLAKE_EDUCATION_IMPORT_NATIVE_SCHEMA_UNSUPPORTED".into()); + } + let columns = native + .columns + .into_iter() + .map(|column| EducationTableColumn { + column_id: format!("COL-{}", Uuid::new_v4()), + title: column.title, + }) + .collect::>(); + let rows = native + .rows + .into_iter() + .map(|row| EducationTableRow { + row_id: format!("ROW-{}", Uuid::new_v4()), + cells: row.cells, + }) + .collect::>(); + validate_table_data(&columns, &rows)?; + Ok(ParsedSheet { + sheet_name: "HoloLake 原生表格".into(), + leading_rows_ignored: 0, + table: ImportedEducationTable { + title: native.title, + columns, + rows, + }, + }) +} + +fn outcome_from_parsed( + source_format: &str, + container_format: &str, + extension_matches_container: bool, + parsed_sheets: Vec, + mut profiles: Vec, +) -> ParseOutcome { + if profiles.is_empty() { + profiles = parsed_sheets + .iter() + .map(|sheet| { + let counts = inferred_cell_counts(&sheet.table); + profile_for_imported_sheet( + &sheet.sheet_name, + &sheet.table, + sheet.leading_rows_ignored, + counts, + 0, + ) + }) + .collect(); + } + let routing = profiles + .iter() + .map(|page| EducationContentRoute { + page_name: page.page_name.clone(), + source_structure: page.structure_kind.clone(), + native_kind: if page.state == "READY_TO_TRANSLATE" { + "HOLOLAKE_TABLE".into() + } else { + "NONE".into() + }, + target_module: if page.state == "READY_TO_TRANSLATE" { + "UNASSIGNED_CHANNEL_STAGING".into() + } else { + "NONE".into() + }, + decision: if page.state == "READY_TO_TRANSLATE" { + "PROFILED_THEN_STAGED_PENDING_HUMAN_ROUTE".into() + } else { + "EMPTY_PAGE_SKIPPED_WITH_RECORD".into() + }, + }) + .collect::>(); + let non_empty_page_count = profiles + .iter() + .filter(|profile| profile.state == "READY_TO_TRANSLATE") + .count(); + let total_data_rows = profiles.iter().map(|profile| profile.data_rows).sum(); + let total_columns = profiles.iter().map(|profile| profile.used_columns).sum(); + ParseOutcome { + source_format: source_format.into(), + content_profile: EducationContentProfile { + schema: "hololake.content-profile/v1", + state: "PROFILED_BEFORE_WRITE", + detected_family: "TABULAR_DATA".into(), + recognition_mode: "DETERMINISTIC_LOCAL".into(), + container_format: container_format.into(), + extension_matches_container, + page_kind: if profiles.len() > 1 { + "WORKSHEET".into() + } else { + "TABLE_PAGE".into() + }, + page_count: profiles.len(), + non_empty_page_count, + total_data_rows, + total_columns, + pages: profiles, + routing, + unresolved_signals: profile_unresolved_signals(container_format), + }, + parsed_sheets, + } +} + +fn profile_unresolved_signals(container_format: &str) -> Vec { + match container_format { + "OOXML_WORKBOOK_ZIP" | "XLSB_WORKBOOK_ZIP" | "OLE_COMPOUND" | "ODS_ZIP" => vec![ + "SOURCE_VISUAL_FORMATTING_IS_PRESENTATION_ONLY_NOT_NATIVE_DATA".into(), + "FORMULAS_ARE_IMPORTED_AS_LAST_STORED_VALUES_UNLESS_EXPLICITLY_REBUILT".into(), + ], + _ => Vec::new(), + } +} + +fn is_human_assistance_case(error: &str) -> bool { + error.starts_with("HOLOLAKE_EDUCATION_IMPORT_") + && !error.starts_with("HOLOLAKE_EDUCATION_IMPORT_UNREADABLE") + && !error.starts_with("HOLOLAKE_EDUCATION_IMPORT_PATH_INVALID") + && !error.starts_with("HOLOLAKE_EDUCATION_IMPORT_FILENAME_INVALID") +} + +fn assistance_receipt_for(source: &Path, reason: &str) -> EducationRecognitionAssistanceReceipt { + let filename = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("未命名文件") + .to_string(); + let bytes = fs::read(source).unwrap_or_default(); + let detected_container = detect_container(&bytes); + let safety_limit = reason.contains("LIMIT_EXCEEDED") + || reason.contains("BOUNDS_INVALID") + || reason.contains("TOO_LARGE"); + let (title, message, eligible) = if safety_limit { + ( + "这个文件超出了当前模块的安全处理范围".to_string(), + "HoloLake 已停止写入并保留原文件。请先拆分文件或减少单页行列;模型辅助不会绕过本机安全上限。".to_string(), + false, + ) + } else { + ( + "当前版本还不能可靠识别这个文件".to_string(), + "HoloLake 没有猜测内容,也没有写入模块。配置模型接口后,可在明确授权当前文件的前提下重新识别,并把新规则保存为仅自己使用或去隐私后共享。".to_string(), + true, + ) + }; + EducationRecognitionAssistanceReceipt { + schema: "hololake.recognition-assistance-receipt/v1", + state: if eligible { + "MODEL_ASSISTANCE_AVAILABLE_AFTER_CONFIGURATION".into() + } else { + "LOCAL_INPUT_CHANGE_REQUIRED".into() + }, + request_id: format!("EDU-RECOGNITION-{}", Uuid::new_v4()), + title, + message, + source_filename: filename, + detected_container, + reason_code: reason.to_string(), + model_assistance_eligible: eligible, + model_api_state: "NOT_CONFIGURED", + model_api_slot: "HOLOLAKE_MODEL_RECOGNITION_API/v1", + requires_explicit_file_consent: true, + source_preserved_read_only: true, + available_learning_scopes: vec!["PRIVATE_ONLY", "SHARE_ANONYMIZED_RULE"], + } +} + +fn profile_for_imported_sheet( + sheet_name: &str, + table: &ImportedEducationTable, + leading_rows_ignored: usize, + counts: CellCounts, + formula_cells: usize, +) -> EducationContentPageProfile { + let focus = infer_semantic_focus(table); + EducationContentPageProfile { + page_name: sheet_name.into(), + state: "READY_TO_TRANSLATE".into(), + used_rows: table.rows.len() + 1, + used_columns: table.columns.len(), + header_row_index: Some(leading_rows_ignored), + header_confidence: if leading_rows_ignored > 0 { + "DENSE_ROW_DETECTED".into() + } else { + "FIRST_NONEMPTY_ROW".into() + }, + data_rows: table.rows.len(), + text_cells: counts.text, + numeric_cells: counts.numeric, + boolean_cells: counts.boolean, + date_cells: counts.date, + formula_cells, + structure_kind: if table.columns.len() == 1 { + "SINGLE_FIELD_LIST".into() + } else { + "RECTANGULAR_TABLE".into() + }, + focus_state: focus.state, + focus_title: focus.title, + primary_measure_column: focus.primary_measure_column, + secondary_measure_columns: focus.secondary_measure_columns, + focus_confidence: focus.confidence, + focus_reasons: focus.reasons, + } +} + +fn infer_semantic_focus(table: &ImportedEducationTable) -> EducationSemanticFocus { + let title = table.title.to_lowercase(); + let compensation_context = contains_any(&title, &["稿费", "收入", "薪酬", "结算", "营收"]); + let achievement_context = contains_any(&title, &["成绩", "考试", "测评", "学员"]); + let attendance_context = contains_any(&title, &["考勤", "出勤", "签到"]); + let mut scored = table + .columns + .iter() + .enumerate() + .filter_map(|(index, column)| { + let name = column.title.trim(); + if name.is_empty() || is_sensitive_header(name) { + return None; + } + let normalized = name.to_lowercase().replace([' ', '_', '-'], ""); + let mut score = semantic_measure_score(&normalized, compensation_context); + let numeric_count = table + .rows + .iter() + .filter(|row| { + parse_semantic_number(row.cells.get(index).map(String::as_str).unwrap_or("")) + .is_some() + }) + .count(); + if !table.rows.is_empty() { + score += ((numeric_count * 30) / table.rows.len()) as i32; + } + (score > 0).then(|| (score, name.to_string())) + }) + .collect::>(); + scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + let primary = scored.first().cloned(); + let focus_title = if compensation_context { + "稿费收入".to_string() + } else if achievement_context { + "学习成绩".to_string() + } else if attendance_context { + "出勤情况".to_string() + } else if let Some((_, column)) = &primary { + column.clone() + } else { + "重点待选择".to_string() + }; + let Some((primary_score, primary_column)) = primary else { + return EducationSemanticFocus { + state: "NEEDS_HUMAN_SELECTION".into(), + title: focus_title, + primary_measure_column: None, + secondary_measure_columns: Vec::new(), + confidence: "LOW".into(), + reasons: vec!["没有发现可可靠计算的重点字段,渲染层必须请人选择".into()], + }; + }; + if primary_score < 70 { + return EducationSemanticFocus { + state: "NEEDS_HUMAN_SELECTION".into(), + title: "重点待选择".into(), + primary_measure_column: None, + secondary_measure_columns: scored.into_iter().take(3).map(|(_, name)| name).collect(), + confidence: "LOW".into(), + reasons: vec!["字段语义和数值覆盖率不足以自动决定主指标".into()], + }; + } + EducationSemanticFocus { + state: "DETERMINISTIC_FOCUS_INFERRED".into(), + title: focus_title.clone(), + primary_measure_column: Some(primary_column.clone()), + secondary_measure_columns: scored + .into_iter() + .skip(1) + .take(3) + .map(|(_, name)| name) + .collect(), + confidence: if primary_score >= 140 { + "HIGH" + } else { + "MEDIUM" + } + .into(), + reasons: vec![ + format!("表名与页名语义指向“{focus_title}”"), + format!("字段“{primary_column}”同时通过语义优先级与数值覆盖率评分"), + ], + } +} + +fn semantic_measure_score(name: &str, compensation_context: bool) -> i32 { + if contains_any( + name, + &["序号", "编号", "账号", "电话", "手机", "id", "日期", "时间"], + ) { + return -200; + } + let mut score = if contains_any(name, &["实际收入", "实收", "到账"]) { + 170 + } else if contains_any(name, &["当月收入", "本月收入", "稿费", "结算金额"]) { + 160 + } else if contains_any(name, &["累计收入", "总收入", "收入合计"]) { + 150 + } else if contains_any(name, &["收入", "营收", "薪酬", "金额", "合计", "总计"]) { + 120 + } else if contains_any( + name, + &[ + "成绩", + "分数", + "得分", + "数量", + "人数", + "课时", + "时长", + "成本", + "预算", + "进度", + "完成率", + ], + ) { + 90 + } else { + 0 + }; + if compensation_context && contains_any(name, &["收入", "稿费", "实收", "到账"]) { + score += 35; + } + score +} + +fn contains_any(value: &str, candidates: &[&str]) -> bool { + candidates.iter().any(|candidate| value.contains(candidate)) +} + +fn is_sensitive_header(value: &str) -> bool { + contains_any( + &value.to_lowercase(), + &[ + "密码", + "口令", + "密钥", + "凭据", + "secret", + "token", + "apikey", + "accesskey", + "credential", + ], + ) +} + +fn parse_semantic_number(value: &str) -> Option { + let normalized = value + .trim() + .replace([',', ',', ' ', '¥', '¥', '$', '€', '£'], "") + .trim_end_matches('%') + .to_string(); + if normalized.is_empty() { + return None; + } + normalized + .parse::() + .ok() + .filter(|value| value.is_finite()) +} + +fn workbook_cell_counts<'a>(cells: impl Iterator) -> CellCounts { + let mut counts = CellCounts::default(); + for cell in cells { + match cell { + Data::Int(_) | Data::Float(_) => counts.numeric += 1, + Data::Bool(_) => counts.boolean += 1, + Data::DateTime(_) | Data::DateTimeIso(_) | Data::DurationIso(_) => counts.date += 1, + Data::String(value) if !value.trim().is_empty() => counts.text += 1, + Data::Error(_) => counts.text += 1, + Data::Empty | Data::String(_) => {} + } + } + counts +} + +fn inferred_cell_counts(table: &ImportedEducationTable) -> CellCounts { + let mut counts = CellCounts { + text: table.columns.len(), + ..CellCounts::default() + }; + for cell in table.rows.iter().flat_map(|row| row.cells.iter()) { + let value = cell.trim(); + if value.is_empty() { + continue; + } + if value.parse::().is_ok() { + counts.numeric += 1; + } else if matches!(value.to_ascii_lowercase().as_str(), "true" | "false") { + counts.boolean += 1; + } else if looks_like_iso_date(value) { + counts.date += 1; + } else { + counts.text += 1; + } + } + counts +} + +fn looks_like_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 10 + && bytes[0..4].iter().all(u8::is_ascii_digit) + && matches!(bytes[4], b'-' | b'/') + && bytes[5..7].iter().all(u8::is_ascii_digit) + && matches!(bytes[7], b'-' | b'/') + && bytes[8..10].iter().all(u8::is_ascii_digit) +} + +fn detect_container(bytes: &[u8]) -> String { + if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) { + return "OLE_COMPOUND".into(); + } + if bytes.starts_with(b"PK\x03\x04") { + if contains_bytes(bytes, b"xl/workbook.bin") { + return "XLSB_WORKBOOK_ZIP".into(); + } + if contains_bytes(bytes, b"xl/workbook.xml") { + return "OOXML_WORKBOOK_ZIP".into(); + } + if contains_bytes(bytes, b"content.xml") && contains_bytes(bytes, b"mimetype") { + return "ODS_ZIP".into(); + } + return "ZIP_CONTAINER".into(); + } + if bytes + .iter() + .take(512) + .all(|byte| *byte == 0 || *byte == 9 || *byte == 10 || *byte == 13 || *byte >= 0x20) + { + return "TEXT_STREAM".into(); + } + "UNKNOWN_BINARY".into() +} + +fn detected_workbook_format(extension: &str, container: &str) -> String { + match container { + "OLE_COMPOUND" => "XLS".into(), + "XLSB_WORKBOOK_ZIP" => "XLSB".into(), + "ODS_ZIP" => "ODS".into(), + "OOXML_WORKBOOK_ZIP" if extension == "xlsm" => "XLSM".into(), + "OOXML_WORKBOOK_ZIP" => "XLSX".into(), + _ => extension.to_ascii_uppercase(), + } +} + +fn extension_matches_container(extension: &str, container: &str) -> bool { + match extension { + "xls" => container == "OLE_COMPOUND", + "xlsb" => container == "XLSB_WORKBOOK_ZIP", + "ods" => container == "ODS_ZIP", + "xlsx" | "xlsm" => container == "OOXML_WORKBOOK_ZIP", + _ => false, + } +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn matrix_to_native_table( + matrix: Vec>, + title: &str, + detect_header: bool, +) -> Result, String> { + let nonempty_rows = matrix + .iter() + .enumerate() + .filter(|(_, row)| row.iter().any(|cell| !cell.trim().is_empty())) + .map(|(index, _)| index) + .collect::>(); + let Some(&first_nonempty) = nonempty_rows.first() else { + return Ok(None); + }; + let header_index = if detect_header { + let mut best_index = first_nonempty; + let mut best_score = 0; + for index in nonempty_rows.iter().copied().take(10) { + let score = matrix[index] + .iter() + .filter(|cell| !cell.trim().is_empty()) + .count(); + if score > best_score { + best_index = index; + best_score = score; + } + } + best_index + } else { + first_nonempty + }; + let last_row = *nonempty_rows.last().unwrap_or(&header_index); + let first_column = matrix[header_index..=last_row] + .iter() + .filter_map(|row| row.iter().position(|cell| !cell.trim().is_empty())) + .min() + .unwrap_or(0); + let last_column = matrix[header_index..=last_row] + .iter() + .filter_map(|row| row.iter().rposition(|cell| !cell.trim().is_empty())) + .max() + .unwrap_or(first_column); + let column_count = last_column.saturating_sub(first_column) + 1; + if column_count == 0 || column_count > MAX_TABLE_COLUMNS { + return Err("HOLOLAKE_EDUCATION_IMPORT_COLUMN_LIMIT_EXCEEDED".into()); + } + let header = &matrix[header_index]; + let mut seen_headers: HashMap = HashMap::new(); + let mut columns = Vec::with_capacity(column_count); + for offset in 0..column_count { + let source_title = header + .get(first_column + offset) + .map(|value| value.trim()) + .unwrap_or_default(); + let base = if source_title.is_empty() { + format!("字段 {}", offset + 1) + } else { + source_title.to_string() + }; + if base.len() > MAX_TITLE_BYTES { + return Err("HOLOLAKE_EDUCATION_IMPORT_HEADER_TOO_LARGE".into()); + } + let count = seen_headers.entry(base.clone()).or_insert(0); + *count += 1; + let title = if *count == 1 { + base + } else { + format!("{} ({})", base, count) + }; + columns.push(EducationTableColumn { + column_id: format!("COL-{}", Uuid::new_v4()), + title, + }); + } + let mut rows = Vec::new(); + for source_row in matrix.iter().take(last_row + 1).skip(header_index + 1) { + let cells = (0..column_count) + .map(|offset| { + source_row + .get(first_column + offset) + .cloned() + .unwrap_or_default() + }) + .collect::>(); + if cells.iter().all(|cell| cell.trim().is_empty()) { + continue; + } + if cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES) { + return Err("HOLOLAKE_EDUCATION_IMPORT_CELL_TOO_LARGE".into()); + } + rows.push(EducationTableRow { + row_id: format!("ROW-{}", Uuid::new_v4()), + cells, + }); + if rows.len() > MAX_TABLE_ROWS { + return Err("HOLOLAKE_EDUCATION_IMPORT_ROW_LIMIT_EXCEEDED".into()); + } + } + let table = ImportedEducationTable { + title: bounded_title(title)?, + columns, + rows, + }; + validate_table_data(&table.columns, &table.rows)?; + Ok(Some((table, header_index))) +} + +fn cell_text(cell: &Data) -> String { + match cell { + Data::DateTime(value) => value + .as_datetime() + .map(|datetime| { + if datetime.and_utc().timestamp() % 86_400 == 0 { + datetime.date().to_string() + } else { + datetime.to_string() + } + }) + .unwrap_or_else(|| value.to_string()), + Data::Empty => String::new(), + _ => cell.to_string(), + } +} + +fn decode_delimited_text(bytes: &[u8]) -> Result { + if let Some(stripped) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8(stripped.to_vec()) + .map_err(|_| "HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); + } + if let Some(stripped) = bytes.strip_prefix(&[0xFF, 0xFE]) { + if stripped.len() % 2 != 0 { + return Err("HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); + } + let values = stripped + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + return String::from_utf16(&values) + .map_err(|_| "HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); + } + if let Some(stripped) = bytes.strip_prefix(&[0xFE, 0xFF]) { + if stripped.len() % 2 != 0 { + return Err("HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); + } + let values = stripped + .chunks_exact(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect::>(); + return String::from_utf16(&values) + .map_err(|_| "HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); + } + if let Ok(value) = String::from_utf8(bytes.to_vec()) { + return Ok(value); + } + let (decoded, _, had_errors) = GBK.decode(bytes); + if had_errors { + Err("HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()) + } else { + Ok(decoded.into_owned()) + } +} + +fn export_table_to_path( + target: &Path, + format: &str, + table: EducationTableExportDraft, +) -> Result { + validate_export_draft(&table)?; + if let Some(parent) = target.parent() { + if !parent.exists() { + return Err("HOLOLAKE_EDUCATION_EXPORT_DIRECTORY_MISSING".into()); + } + } + match format { + "XLSX" => write_xlsx(target, &table)?, + "CSV" => write_delimited(target, &table, b',')?, + "TSV" => write_delimited(target, &table, b'\t')?, + "HOLOLAKE_NATIVE" => write_native_table(target, &table)?, + _ => return Err("HOLOLAKE_EDUCATION_EXPORT_FORMAT_UNSUPPORTED".into()), + } + let metadata = fs::metadata(target) + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_VERIFY_FAILED: {error}"))?; + Ok(EducationTableExportReceipt { + schema: TRANSLATOR_SCHEMA, + state: "EXPORTED_FROM_NATIVE", + adapter_id: EXPORT_ADAPTER, + table_id: table.table_id, + table_revision: table.revision, + target_format: format.to_string(), + target_filename: target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("教育表格") + .to_string(), + bytes: metadata.len(), + row_count: table.rows.len(), + column_count: table.columns.len(), + exported_at_unix_ms: now_ms(), + }) +} + +fn write_xlsx(target: &Path, table: &EducationTableExportDraft) -> Result<(), String> { + let mut workbook = Workbook::new(); + let worksheet = workbook.add_worksheet(); + worksheet + .set_name("HoloLake 数据") + .map_err(export_xlsx_error)?; + let header = Format::new() + .set_bold() + .set_font_color(Color::RGB(0xF7EDC5)) + .set_background_color(Color::RGB(0x111827)); + for (column_index, column) in table.columns.iter().enumerate() { + worksheet + .write_string_with_format(0, column_index as u16, &column.title, &header) + .map_err(export_xlsx_error)?; + } + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.cells.iter().enumerate() { + worksheet + .write_string((row_index + 1) as u32, column_index as u16, cell) + .map_err(export_xlsx_error)?; + } + } + worksheet + .set_freeze_panes(1, 0) + .map_err(export_xlsx_error)?; + worksheet.autofit(); + workbook.save(target).map_err(export_xlsx_error) +} + +fn write_delimited( + target: &Path, + table: &EducationTableExportDraft, + delimiter: u8, +) -> Result<(), String> { + let mut writer = csv::WriterBuilder::new() + .delimiter(delimiter) + .from_writer(Vec::new()); + writer + .write_record(table.columns.iter().map(|column| column.title.as_str())) + .map_err(export_csv_error)?; + for row in &table.rows { + let protected = row + .cells + .iter() + .map(|cell| protect_spreadsheet_formula(cell)) + .collect::>(); + writer.write_record(protected).map_err(export_csv_error)?; + } + writer + .flush() + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_DELIMITED_FAILED: {error}"))?; + let bytes = writer + .into_inner() + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_DELIMITED_FAILED: {error}"))?; + let mut with_bom = Vec::with_capacity(bytes.len() + 3); + with_bom.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + with_bom.extend_from_slice(&bytes); + fs::write(target, with_bom) + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_WRITE_FAILED: {error}")) +} + +fn write_native_table(target: &Path, table: &EducationTableExportDraft) -> Result<(), String> { + let native = NativeEducationTableFile { + schema: NATIVE_TABLE_SCHEMA.into(), + title: table.title.clone(), + columns: table.columns.clone(), + rows: table.rows.clone(), + }; + let bytes = serde_json::to_vec_pretty(&native) + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_NATIVE_FAILED: {error}"))?; + fs::write(target, bytes) + .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_WRITE_FAILED: {error}")) +} + +fn validate_export_draft(table: &EducationTableExportDraft) -> Result<(), String> { + if !table.table_id.starts_with("EDU-TABLE-") || table.revision < 1 { + return Err("HOLOLAKE_EDUCATION_EXPORT_TABLE_INVALID".into()); + } + bounded_title(&table.title)?; + validate_table_data(&table.columns, &table.rows) +} + +fn normalized_export_format(value: &str) -> Result { + let value = value.trim().to_ascii_uppercase(); + if matches!(value.as_str(), "XLSX" | "CSV" | "TSV" | "HOLOLAKE_NATIVE") { + Ok(value) + } else { + Err("HOLOLAKE_EDUCATION_EXPORT_FORMAT_UNSUPPORTED".into()) + } +} + +fn export_filter_label(format: &str) -> &'static str { + match format { + "XLSX" => "Excel 工作簿", + "CSV" => "CSV 表格", + "TSV" => "TSV 表格", + "HOLOLAKE_NATIVE" => "HoloLake 原生表格", + _ => "表格文件", + } +} + +fn protect_spreadsheet_formula(cell: &str) -> String { + if cell + .trim_start() + .chars() + .next() + .is_some_and(|character| matches!(character, '=' | '+' | '-' | '@')) + { + format!("'{cell}") + } else { + cell.to_string() + } +} + +fn bounded_title(value: &str) -> Result { + let title = value.trim(); + if title.is_empty() + || title.len() > MAX_TITLE_BYTES + || title + .chars() + .any(|character| matches!(character, '\0' | '\r' | '\n')) + { + Err("HOLOLAKE_EDUCATION_IMPORT_TITLE_INVALID".into()) + } else { + Ok(title.to_string()) + } +} + +fn workbook_table_title(stem: &str, sheet_name: &str) -> String { + let candidate = format!("{stem} · {sheet_name}"); + if candidate.len() <= MAX_TITLE_BYTES { + candidate + } else if sheet_name.len() <= MAX_TITLE_BYTES { + sheet_name.to_string() + } else { + "导入的教育表格".into() + } +} + +fn file_stem(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("导入的教育表格") + .to_string() +} + +fn safe_filename(value: &str) -> String { + let name = value + .chars() + .map(|character| { + if matches!( + character, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' + ) || character.is_control() + { + '_' + } else { + character + } + }) + .collect::(); + let name = name.trim().trim_matches('.'); + if name.is_empty() { + "教育表格".into() + } else { + name.to_string() + } +} + +fn hex_digest(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn export_xlsx_error(error: rust_xlsxwriter::XlsxError) -> String { + format!("HOLOLAKE_EDUCATION_EXPORT_XLSX_FAILED: {error}") +} + +fn export_csv_error(error: csv::Error) -> String { + format!("HOLOLAKE_EDUCATION_EXPORT_DELIMITED_FAILED: {error}") +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn csv_round_trip_preserves_chinese_and_blocks_formula_injection() { + let directory = tempdir().unwrap(); + let source = directory.path().join("学员.csv"); + fs::write(&source, "姓名,状态\n冰朔,连载中\n危险,=cmd()\n").unwrap(); + let database = directory.path().join("education.sqlite3"); + let receipt = import_tables_from_path(&database, &source).unwrap(); + assert_eq!(receipt.imported_tables.len(), 1); + let imported = crate::education_workspace::read_table_at( + &database, + &receipt.imported_tables[0].table_id, + ) + .unwrap(); + assert_eq!(imported.rows[0].cells, vec!["冰朔", "连载中"]); + let export = directory.path().join("export.csv"); + export_table_to_path( + &export, + "CSV", + EducationTableExportDraft { + table_id: imported.table_id, + title: imported.title, + columns: imported.columns, + rows: imported.rows, + revision: imported.revision, + }, + ) + .unwrap(); + let exported = fs::read_to_string(export).unwrap(); + assert!(exported.contains("危险,'=cmd()")); + } + + #[test] + fn xlsx_import_uses_dense_header_and_exports_valid_workbook() { + let directory = tempdir().unwrap(); + let source = directory.path().join("稿费.xlsx"); + let mut workbook = Workbook::new(); + let worksheet = workbook.add_worksheet(); + worksheet.write_string(0, 0, "稿费汇总").unwrap(); + worksheet.write_string(2, 0, "作品").unwrap(); + worksheet.write_string(2, 1, "月份").unwrap(); + worksheet.write_string(2, 2, "金额").unwrap(); + worksheet.write_string(3, 0, "测试作品").unwrap(); + worksheet.write_string(3, 1, "2025-10").unwrap(); + worksheet.write_number(3, 2, 1234.5).unwrap(); + workbook.save(&source).unwrap(); + let database = directory.path().join("education.sqlite3"); + let receipt = import_tables_from_path(&database, &source).unwrap(); + assert_eq!(receipt.imported_tables[0].leading_rows_ignored, 2); + assert_eq!(receipt.imported_tables[0].column_count, 3); + assert_eq!(receipt.imported_tables[0].row_count, 1); + assert_eq!(receipt.content_profile.pages[0].focus_title, "稿费收入"); + assert_eq!( + receipt.content_profile.pages[0] + .primary_measure_column + .as_deref(), + Some("金额") + ); + let imported = crate::education_workspace::read_table_at( + &database, + &receipt.imported_tables[0].table_id, + ) + .unwrap(); + let target = directory.path().join("roundtrip.xlsx"); + export_table_to_path( + &target, + "XLSX", + EducationTableExportDraft { + table_id: imported.table_id, + title: imported.title, + columns: imported.columns, + rows: imported.rows, + revision: imported.revision, + }, + ) + .unwrap(); + let mut roundtrip = open_workbook_auto(target).unwrap(); + let range = roundtrip.worksheet_range("HoloLake 数据").unwrap(); + assert_eq!(range.get_value((0, 0)).unwrap().to_string(), "作品"); + assert_eq!(range.get_value((1, 2)).unwrap().to_string(), "1234.5"); + } + + #[test] + fn oversized_sheet_fails_instead_of_truncating() { + let mut matrix = vec![(0..31).map(|index| format!("字段 {index}")).collect()]; + matrix.push((0..31).map(|_| "值".to_string()).collect()); + assert_eq!( + matrix_to_native_table(matrix, "超限", false).unwrap_err(), + "HOLOLAKE_EDUCATION_IMPORT_COLUMN_LIMIT_EXCEEDED" + ); + } + + #[test] + fn ambiguous_table_keeps_the_focus_as_a_human_choice() { + let table = ImportedEducationTable { + title: "杂项记录".into(), + columns: vec![ + EducationTableColumn { + column_id: "COL-A".into(), + title: "内容甲".into(), + }, + EducationTableColumn { + column_id: "COL-B".into(), + title: "内容乙".into(), + }, + ], + rows: vec![EducationTableRow { + row_id: "ROW-A".into(), + cells: vec!["甲".into(), "乙".into()], + }], + }; + let focus = infer_semantic_focus(&table); + assert_eq!(focus.state, "NEEDS_HUMAN_SELECTION"); + assert_eq!(focus.primary_measure_column, None); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/education_workspace.rs b/product-source/hololake-native-desktop/src-tauri/src/education_workspace.rs new file mode 100644 index 000000000..bbd631d21 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/education_workspace.rs @@ -0,0 +1,1855 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 教育行业初始化频道的原生文档与表格数据层。 +//! +//! 公共主世界只投影模块状态;真实内容必须进入已登录账号的独立存储根。 +//! WebView 不直接持有文件路径或 SQLite 连接,所有写入都要求当前修订号,防止静默覆盖。 + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, types::Type, Connection, OptionalExtension}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const WORKSPACE_SCHEMA: &str = "hololake.education-workspace/v1"; +const MAX_DOCUMENT_BYTES: usize = 2_000_000; +const MAX_TITLE_BYTES: usize = 300; +const MAX_TABLE_COLUMNS: usize = 30; +const MAX_TABLE_ROWS: usize = 1_000; +const MAX_CELL_BYTES: usize = 10_000; +const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001"; +const ADAPTER: &str = "education-workbench-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationDocumentSummary { + pub document_id: String, + pub title: String, + pub revision: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationDocument { + pub document_id: String, + pub title: String, + pub body: String, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTableSummary { + pub table_id: String, + pub title: String, + pub revision: i64, + pub column_count: usize, + pub row_count: usize, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnassignedTableSummary { + pub table_id: String, + pub title: String, + pub column_count: usize, + pub row_count: usize, + pub imported_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EducationTableColumn { + pub column_id: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EducationTableRow { + pub row_id: String, + pub cells: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTable { + pub table_id: String, + pub title: String, + pub columns: Vec, + pub rows: Vec, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug)] +pub(crate) struct ImportedEducationTable { + pub title: String, + pub columns: Vec, + pub rows: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct EducationImportRegistration { + pub source_filename: String, + pub source_format: String, + pub source_sha256: String, + pub profile_json: String, + pub imported_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationImportRegistrySummary { + pub import_id: String, + pub source_filename: String, + pub source_format: String, + pub table_count: usize, + pub imported_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationWorkspaceSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub documents: Vec, + pub tables: Vec, + pub automation_rules: Vec, + pub recent_automation_runs: Vec, + pub recent_imports: Vec, + pub unassigned_tables: Vec, + pub document_count: usize, + pub table_count: usize, + pub automation_rule_count: usize, + pub unassigned_count: usize, + pub storage: &'static str, + pub authority: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationAutomationRule { + pub rule_id: String, + pub title: String, + pub table_id: String, + pub condition_column_id: String, + pub operator: String, + pub condition_value: String, + pub action_column_id: String, + pub action_value: String, + pub enabled: bool, + pub revision: i64, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationAutomationPreview { + pub state: &'static str, + pub rule_id: String, + pub table_id: String, + pub rule_revision: i64, + pub table_revision: i64, + pub matched_rows: usize, + pub changed_cells: usize, + pub preview_token: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationAutomationRunReceipt { + pub state: String, + pub run_id: String, + pub rule_id: String, + pub table_id: String, + pub matched_rows: usize, + pub changed_cells: usize, + pub from_table_revision: i64, + pub to_table_revision: i64, + pub ran_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateEducationItemInput { + pub title: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadEducationDocumentInput { + pub document_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveEducationDocumentInput { + pub document_id: String, + pub title: String, + pub body: String, + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadEducationTableInput { + pub table_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveEducationTableInput { + pub table_id: String, + pub title: String, + pub columns: Vec, + pub rows: Vec, + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateEducationAutomationRuleInput { + pub title: String, + pub table_id: String, + pub condition_column_id: String, + pub operator: String, + pub condition_value: String, + pub action_column_id: String, + pub action_value: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveEducationAutomationRuleInput { + pub rule_id: String, + pub title: String, + pub table_id: String, + pub condition_column_id: String, + pub operator: String, + pub condition_value: String, + pub action_column_id: String, + pub action_value: String, + pub enabled: bool, + pub expected_revision: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EducationAutomationRuleInput { + pub rule_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PreviewEducationAutomationRuleInput { + pub rule_id: String, + pub expected_rule_revision: i64, + pub expected_table_revision: i64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExecuteEducationAutomationRuleInput { + pub rule_id: String, + pub expected_rule_revision: i64, + pub expected_table_revision: i64, + pub preview_token: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationArchiveReceipt { + pub state: &'static str, + pub entity_id: String, +} + +pub async fn get_education_workspace_snapshot( + app: AppHandle, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn create_education_document( + app: AppHandle, + input: CreateEducationItemInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_document_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn read_education_document( + app: AppHandle, + input: ReadEducationDocumentInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || read_document_at(&database, &input.document_id)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn save_education_document( + app: AppHandle, + input: SaveEducationDocumentInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || save_document_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn archive_education_document( + app: AppHandle, + input: ReadEducationDocumentInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + archive_at( + &database, + "education_documents", + "document_id", + input.document_id, + ) + }) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn create_education_table( + app: AppHandle, + input: CreateEducationItemInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_table_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn read_education_table( + app: AppHandle, + input: ReadEducationTableInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || read_table_at(&database, &input.table_id)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn save_education_table( + app: AppHandle, + input: SaveEducationTableInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || save_table_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn archive_education_table( + app: AppHandle, + input: ReadEducationTableInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + archive_at(&database, "education_tables", "table_id", input.table_id) + }) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn assign_imported_table_to_education( + app: AppHandle, + input: ReadEducationTableInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + assign_imported_table_to_education_at(&database, &input.table_id) + }) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn create_education_automation_rule( + app: AppHandle, + input: CreateEducationAutomationRuleInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_automation_rule_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn save_education_automation_rule( + app: AppHandle, + input: SaveEducationAutomationRuleInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || save_automation_rule_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn archive_education_automation_rule( + app: AppHandle, + input: EducationAutomationRuleInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + archive_automation_rule_at(&database, input.rule_id) + }) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn preview_education_automation_rule( + app: AppHandle, + input: PreviewEducationAutomationRuleInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || preview_automation_rule_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub async fn execute_education_automation_rule( + app: AppHandle, + input: ExecuteEducationAutomationRuleInput, +) -> Result { + require_active(&app)?; + let database = education_workspace_database(&app)?; + tauri::async_runtime::spawn_blocking(move || execute_automation_rule_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? +} + +pub(crate) fn education_workspace_database(app: &AppHandle) -> Result { + let root = crate::authenticated_storage::account_storage_root(app, "education-workspace-v1")?; + create_private_directory(&root)?; + Ok(root.join("education-workspace.sqlite3")) +} + +fn create_private_directory(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("HOLOLAKE_EDUCATION_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_EDUCATION_STORAGE_PERMISSION_FAILED: {error}"))?; + } + Ok(()) +} + +fn open_database(path: &Path) -> Result { + if let Some(parent) = path.parent() { + create_private_directory(parent)?; + } + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_UNAVAILABLE: {error}"))?; + connection + .execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS workspace_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + INSERT OR IGNORE INTO workspace_meta(key, value) VALUES ('schema_version', '1'); + CREATE TABLE IF NOT EXISTS education_documents ( + document_id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS education_tables ( + table_id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + columns_json TEXT NOT NULL, + rows_json TEXT NOT NULL, + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED')), + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS education_automation_rules ( + rule_id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + table_id TEXT NOT NULL, + condition_column_id TEXT NOT NULL, + operator TEXT NOT NULL, + condition_value TEXT NOT NULL, + action_column_id TEXT NOT NULL, + action_value TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), + revision INTEGER NOT NULL, + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS education_automation_runs ( + run_id TEXT PRIMARY KEY NOT NULL, + rule_id TEXT NOT NULL, + table_id TEXT NOT NULL, + matched_rows INTEGER NOT NULL, + changed_cells INTEGER NOT NULL, + from_table_revision INTEGER NOT NULL, + to_table_revision INTEGER NOT NULL, + ran_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS education_import_registry ( + import_id TEXT PRIMARY KEY NOT NULL, + source_filename TEXT NOT NULL, + source_format TEXT NOT NULL, + source_sha256 TEXT NOT NULL, + profile_json TEXT NOT NULL, + table_ids_json TEXT NOT NULL, + imported_at_unix_ms INTEGER NOT NULL + );", + ) + .map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_MIGRATION_FAILED: {error}"))?; + if !table_has_column(&connection, "education_tables", "assignment_scope")? { + connection + .execute( + "ALTER TABLE education_tables ADD COLUMN assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED'))", + [], + ) + .map_err(database_write_error)?; + let imported_table_ids = { + let mut statement = connection + .prepare("SELECT table_ids_json FROM education_import_registry") + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(database_read_error)?; + let encoded = rows + .collect::, _>>() + .map_err(database_read_error)?; + encoded + .into_iter() + .map(|value| decode_sqlite_json::>(&value, 0)) + .collect::, _>>() + .map_err(database_read_error)? + .into_iter() + .flatten() + .collect::>() + }; + for table_id in imported_table_ids { + connection + .execute( + "UPDATE education_tables SET assignment_scope = 'UNASSIGNED' WHERE table_id = ?1 AND archived = 0", + [table_id], + ) + .map_err(database_write_error)?; + } + connection + .execute( + "INSERT INTO workspace_meta(key, value) VALUES ('schema_version', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [], + ) + .map_err(database_write_error)?; + } + #[cfg(unix)] + if path.exists() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_PERMISSION_FAILED: {error}"))?; + } + Ok(connection) +} + +fn snapshot_at(path: &Path) -> Result { + let connection = open_database(path)?; + let documents = { + let mut statement = connection + .prepare( + "SELECT document_id, title, revision, updated_at_unix_ms + FROM education_documents WHERE archived = 0 + ORDER BY updated_at_unix_ms DESC, title ASC", + ) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| { + Ok(EducationDocumentSummary { + document_id: row.get(0)?, + title: row.get(1)?, + revision: row.get(2)?, + updated_at_unix_ms: row.get(3)?, + }) + }) + .map_err(database_read_error)?; + rows.collect::, _>>() + .map_err(database_read_error)? + }; + let tables = { + let mut statement = connection + .prepare( + "SELECT table_id, title, columns_json, rows_json, revision, updated_at_unix_ms + FROM education_tables WHERE archived = 0 AND assignment_scope = 'EDUCATION' + ORDER BY updated_at_unix_ms DESC, title ASC", + ) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| { + let columns_json: String = row.get(2)?; + let rows_json: String = row.get(3)?; + let columns: Vec = decode_sqlite_json(&columns_json, 2)?; + let rows: Vec = decode_sqlite_json(&rows_json, 3)?; + Ok(EducationTableSummary { + table_id: row.get(0)?, + title: row.get(1)?, + column_count: columns.len(), + row_count: rows.len(), + revision: row.get(4)?, + updated_at_unix_ms: row.get(5)?, + }) + }) + .map_err(database_read_error)?; + rows.collect::, _>>() + .map_err(database_read_error)? + }; + let automation_rules = read_automation_rules_with_connection(&connection)?; + let recent_automation_runs = read_recent_automation_runs_with_connection(&connection)?; + let recent_imports = read_recent_imports_with_connection(&connection)?; + let unassigned_tables = read_unassigned_tables_with_connection(&connection)?; + Ok(EducationWorkspaceSnapshot { + schema: WORKSPACE_SCHEMA, + state: "READY", + document_count: documents.len(), + table_count: tables.len(), + automation_rule_count: automation_rules.len(), + documents, + tables, + automation_rules, + recent_automation_runs, + recent_imports, + unassigned_count: unassigned_tables.len(), + unassigned_tables, + storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE", + authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY", + }) +} + +fn read_unassigned_tables_with_connection( + connection: &Connection, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT table_id, title, columns_json, rows_json, updated_at_unix_ms + FROM education_tables + WHERE archived = 0 AND assignment_scope = 'UNASSIGNED' + ORDER BY updated_at_unix_ms DESC, title ASC", + ) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| { + let columns_json: String = row.get(2)?; + let rows_json: String = row.get(3)?; + let columns: Vec = decode_sqlite_json(&columns_json, 2)?; + let rows: Vec = decode_sqlite_json(&rows_json, 3)?; + Ok(UnassignedTableSummary { + table_id: row.get(0)?, + title: row.get(1)?, + column_count: columns.len(), + row_count: rows.len(), + imported_at_unix_ms: row.get(4)?, + }) + }) + .map_err(database_read_error)?; + rows.collect::, _>>() + .map_err(database_read_error) +} + +fn read_recent_imports_with_connection( + connection: &Connection, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT import_id, source_filename, source_format, table_ids_json, imported_at_unix_ms + FROM education_import_registry ORDER BY imported_at_unix_ms DESC LIMIT 20", + ) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| { + let table_ids_json: String = row.get(3)?; + let table_ids: Vec = decode_sqlite_json(&table_ids_json, 3)?; + Ok(EducationImportRegistrySummary { + import_id: row.get(0)?, + source_filename: row.get(1)?, + source_format: row.get(2)?, + table_count: table_ids.len(), + imported_at_unix_ms: row.get(4)?, + }) + }) + .map_err(database_read_error)?; + rows.collect::, _>>() + .map_err(database_read_error) +} + +fn read_automation_rules_with_connection( + connection: &Connection, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT rule_id, title, table_id, condition_column_id, operator, + condition_value, action_column_id, action_value, enabled, revision, + created_at_unix_ms, updated_at_unix_ms + FROM education_automation_rules WHERE archived = 0 + ORDER BY updated_at_unix_ms DESC, title ASC", + ) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| { + Ok(EducationAutomationRule { + rule_id: row.get(0)?, + title: row.get(1)?, + table_id: row.get(2)?, + condition_column_id: row.get(3)?, + operator: row.get(4)?, + condition_value: row.get(5)?, + action_column_id: row.get(6)?, + action_value: row.get(7)?, + enabled: row.get::<_, i64>(8)? == 1, + revision: row.get(9)?, + created_at_unix_ms: row.get(10)?, + updated_at_unix_ms: row.get(11)?, + }) + }) + .map_err(database_read_error)?; + rows.collect::, _>>() + .map_err(database_read_error) +} + +fn read_recent_automation_runs_with_connection( + connection: &Connection, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT run_id, rule_id, table_id, matched_rows, changed_cells, + from_table_revision, to_table_revision, ran_at_unix_ms + FROM education_automation_runs ORDER BY ran_at_unix_ms DESC LIMIT 20", + ) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| { + let changed_cells = row.get::<_, i64>(4)? as usize; + Ok(EducationAutomationRunReceipt { + state: if changed_cells > 0 { + "APPLIED" + } else { + "UNCHANGED" + } + .into(), + run_id: row.get(0)?, + rule_id: row.get(1)?, + table_id: row.get(2)?, + matched_rows: row.get::<_, i64>(3)? as usize, + changed_cells, + from_table_revision: row.get(5)?, + to_table_revision: row.get(6)?, + ran_at_unix_ms: row.get(7)?, + }) + }) + .map_err(database_read_error)?; + rows.collect::, _>>() + .map_err(database_read_error) +} + +fn create_document_at( + path: &Path, + input: CreateEducationItemInput, +) -> Result { + let title = normalized_title(&input.title, "未命名教育文档")?; + let document_id = format!("EDU-DOC-{}", Uuid::new_v4()); + let now = now_ms(); + let connection = open_database(path)?; + connection + .execute( + "INSERT INTO education_documents( + document_id, title, body, revision, created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES (?1, ?2, '', 1, ?3, ?3, 0)", + params![document_id, title, now], + ) + .map_err(database_write_error)?; + read_document_with_connection(&connection, &document_id) +} + +fn read_document_at(path: &Path, document_id: &str) -> Result { + let connection = open_database(path)?; + read_document_with_connection(&connection, document_id) +} + +fn read_document_with_connection( + connection: &Connection, + document_id: &str, +) -> Result { + validate_entity_id(document_id, "EDU-DOC-")?; + connection + .query_row( + "SELECT document_id, title, body, revision, created_at_unix_ms, updated_at_unix_ms + FROM education_documents WHERE document_id = ?1 AND archived = 0", + [document_id], + |row| { + Ok(EducationDocument { + document_id: row.get(0)?, + title: row.get(1)?, + body: row.get(2)?, + revision: row.get(3)?, + created_at_unix_ms: row.get(4)?, + updated_at_unix_ms: row.get(5)?, + }) + }, + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_EDUCATION_DOCUMENT_NOT_FOUND".to_string()) +} + +fn save_document_at( + path: &Path, + input: SaveEducationDocumentInput, +) -> Result { + validate_entity_id(&input.document_id, "EDU-DOC-")?; + let title = normalized_title(&input.title, "未命名教育文档")?; + if input.body.len() > MAX_DOCUMENT_BYTES || input.expected_revision < 1 { + return Err("HOLOLAKE_EDUCATION_DOCUMENT_INPUT_INVALID".into()); + } + let connection = open_database(path)?; + let changed = connection + .execute( + "UPDATE education_documents + SET title = ?1, body = ?2, revision = revision + 1, updated_at_unix_ms = ?3 + WHERE document_id = ?4 AND revision = ?5 AND archived = 0", + params![ + title, + input.body, + now_ms(), + input.document_id, + input.expected_revision + ], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_DOCUMENT_REVISION_CONFLICT".into()); + } + read_document_with_connection(&connection, &input.document_id) +} + +fn create_table_at(path: &Path, input: CreateEducationItemInput) -> Result { + let title = normalized_title(&input.title, "未命名教育表格")?; + let table_id = format!("EDU-TABLE-{}", Uuid::new_v4()); + let columns = vec![ + EducationTableColumn { + column_id: format!("COL-{}", Uuid::new_v4()), + title: "姓名".into(), + }, + EducationTableColumn { + column_id: format!("COL-{}", Uuid::new_v4()), + title: "类别".into(), + }, + EducationTableColumn { + column_id: format!("COL-{}", Uuid::new_v4()), + title: "状态".into(), + }, + ]; + let rows: Vec = Vec::new(); + let now = now_ms(); + let connection = open_database(path)?; + connection + .execute( + "INSERT INTO education_tables( + table_id, title, columns_json, rows_json, revision, + created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'EDUCATION', 0)", + params![ + table_id, + title, + serde_json::to_string(&columns).map_err(json_write_error)?, + serde_json::to_string(&rows).map_err(json_write_error)?, + now + ], + ) + .map_err(database_write_error)?; + read_table_with_connection(&connection, &table_id) +} + +pub(crate) fn read_table_at(path: &Path, table_id: &str) -> Result { + let connection = open_database(path)?; + read_table_with_connection(&connection, table_id) +} + +pub(crate) fn import_table_batch_at( + path: &Path, + imported: Vec, + registration: EducationImportRegistration, +) -> Result<(String, Vec), String> { + if imported.is_empty() { + return Err("HOLOLAKE_EDUCATION_IMPORT_EMPTY".into()); + } + for table in &imported { + normalized_title(&table.title, "导入的教育表格")?; + validate_table_data(&table.columns, &table.rows)?; + } + let mut connection = open_database(path)?; + let transaction = connection.transaction().map_err(database_write_error)?; + let now = now_ms(); + let mut table_ids = Vec::with_capacity(imported.len()); + for table in imported { + let table_id = format!("EDU-TABLE-{}", Uuid::new_v4()); + let title = normalized_title(&table.title, "导入的教育表格")?; + transaction + .execute( + "INSERT INTO education_tables( + table_id, title, columns_json, rows_json, revision, + created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'UNASSIGNED', 0)", + params![ + table_id, + title, + serde_json::to_string(&table.columns).map_err(json_write_error)?, + serde_json::to_string(&table.rows).map_err(json_write_error)?, + now + ], + ) + .map_err(database_write_error)?; + table_ids.push(table_id); + } + let import_id = format!("EDU-IMPORT-{}", Uuid::new_v4()); + transaction + .execute( + "INSERT INTO education_import_registry( + import_id, source_filename, source_format, source_sha256, + profile_json, table_ids_json, imported_at_unix_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + import_id, + registration.source_filename, + registration.source_format, + registration.source_sha256, + registration.profile_json, + serde_json::to_string(&table_ids).map_err(json_write_error)?, + registration.imported_at_unix_ms + ], + ) + .map_err(database_write_error)?; + transaction.commit().map_err(database_write_error)?; + let tables = table_ids + .iter() + .map(|table_id| read_table_with_connection(&connection, table_id)) + .collect::, _>>()?; + Ok((import_id, tables)) +} + +fn assign_imported_table_to_education_at( + path: &Path, + table_id: &str, +) -> Result { + validate_entity_id(table_id, "EDU-TABLE-")?; + let connection = open_database(path)?; + let changed = connection + .execute( + "UPDATE education_tables + SET assignment_scope = 'EDUCATION', updated_at_unix_ms = ?1 + WHERE table_id = ?2 AND assignment_scope = 'UNASSIGNED' AND archived = 0", + params![now_ms(), table_id], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_IMPORT_ASSIGNMENT_NOT_FOUND".into()); + } + read_table_with_connection(&connection, table_id) +} + +fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(database_read_error)?; + let rows = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(database_read_error)?; + let found = rows.filter_map(Result::ok).any(|name| name == column); + Ok(found) +} + +fn read_table_with_connection( + connection: &Connection, + table_id: &str, +) -> Result { + validate_entity_id(table_id, "EDU-TABLE-")?; + let stored = connection + .query_row( + "SELECT table_id, title, columns_json, rows_json, revision, + created_at_unix_ms, updated_at_unix_ms + FROM education_tables WHERE table_id = ?1 AND archived = 0", + [table_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + }, + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_EDUCATION_TABLE_NOT_FOUND".to_string())?; + let columns = serde_json::from_str(&stored.2) + .map_err(|error| format!("HOLOLAKE_EDUCATION_TABLE_DATA_INVALID: {error}"))?; + let rows = serde_json::from_str(&stored.3) + .map_err(|error| format!("HOLOLAKE_EDUCATION_TABLE_DATA_INVALID: {error}"))?; + Ok(EducationTable { + table_id: stored.0, + title: stored.1, + columns, + rows, + revision: stored.4, + created_at_unix_ms: stored.5, + updated_at_unix_ms: stored.6, + }) +} + +fn save_table_at(path: &Path, input: SaveEducationTableInput) -> Result { + validate_entity_id(&input.table_id, "EDU-TABLE-")?; + let title = normalized_title(&input.title, "未命名教育表格")?; + validate_table_data(&input.columns, &input.rows)?; + if input.expected_revision < 1 { + return Err("HOLOLAKE_EDUCATION_TABLE_INPUT_INVALID".into()); + } + let columns_json = serde_json::to_string(&input.columns).map_err(json_write_error)?; + let rows_json = serde_json::to_string(&input.rows).map_err(json_write_error)?; + let connection = open_database(path)?; + let changed = connection + .execute( + "UPDATE education_tables + SET title = ?1, columns_json = ?2, rows_json = ?3, + revision = revision + 1, updated_at_unix_ms = ?4 + WHERE table_id = ?5 AND revision = ?6 AND archived = 0", + params![ + title, + columns_json, + rows_json, + now_ms(), + input.table_id, + input.expected_revision + ], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_TABLE_REVISION_CONFLICT".into()); + } + read_table_with_connection(&connection, &input.table_id) +} + +fn normalized_operator(value: &str) -> Result { + let operator = value.trim().to_ascii_uppercase(); + if matches!( + operator.as_str(), + "EQUALS" | "CONTAINS" | "IS_EMPTY" | "IS_NOT_EMPTY" + ) { + Ok(operator) + } else { + Err("HOLOLAKE_EDUCATION_AUTOMATION_OPERATOR_INVALID".into()) + } +} + +fn validate_automation_rule_against_table( + table: &EducationTable, + condition_column_id: &str, + action_column_id: &str, + action_value: &str, +) -> Result<(), String> { + let column_ids = table + .columns + .iter() + .map(|column| column.column_id.as_str()) + .collect::>(); + if !column_ids.contains(condition_column_id) + || !column_ids.contains(action_column_id) + || action_value.len() > MAX_CELL_BYTES + { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_COLUMN_INVALID".into()); + } + Ok(()) +} + +fn create_automation_rule_at( + path: &Path, + input: CreateEducationAutomationRuleInput, +) -> Result { + validate_entity_id(&input.table_id, "EDU-TABLE-")?; + let title = normalized_title(&input.title, "未命名自动化规则")?; + let operator = normalized_operator(&input.operator)?; + if input.condition_value.len() > MAX_CELL_BYTES { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_VALUE_INVALID".into()); + } + let connection = open_database(path)?; + let table = read_table_with_connection(&connection, &input.table_id)?; + validate_automation_rule_against_table( + &table, + &input.condition_column_id, + &input.action_column_id, + &input.action_value, + )?; + let rule_id = format!("EDU-AUTO-{}", Uuid::new_v4()); + let now = now_ms(); + connection + .execute( + "INSERT INTO education_automation_rules( + rule_id, title, table_id, condition_column_id, operator, condition_value, + action_column_id, action_value, enabled, revision, + created_at_unix_ms, updated_at_unix_ms, archived + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1, ?9, ?9, 0)", + params![ + rule_id, + title, + input.table_id, + input.condition_column_id, + operator, + input.condition_value, + input.action_column_id, + input.action_value, + now + ], + ) + .map_err(database_write_error)?; + read_automation_rule_with_connection(&connection, &rule_id) +} + +fn read_automation_rule_with_connection( + connection: &Connection, + rule_id: &str, +) -> Result { + validate_entity_id(rule_id, "EDU-AUTO-")?; + connection + .query_row( + "SELECT rule_id, title, table_id, condition_column_id, operator, + condition_value, action_column_id, action_value, enabled, revision, + created_at_unix_ms, updated_at_unix_ms + FROM education_automation_rules WHERE rule_id = ?1 AND archived = 0", + [rule_id], + |row| { + Ok(EducationAutomationRule { + rule_id: row.get(0)?, + title: row.get(1)?, + table_id: row.get(2)?, + condition_column_id: row.get(3)?, + operator: row.get(4)?, + condition_value: row.get(5)?, + action_column_id: row.get(6)?, + action_value: row.get(7)?, + enabled: row.get::<_, i64>(8)? == 1, + revision: row.get(9)?, + created_at_unix_ms: row.get(10)?, + updated_at_unix_ms: row.get(11)?, + }) + }, + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_EDUCATION_AUTOMATION_RULE_NOT_FOUND".to_string()) +} + +fn save_automation_rule_at( + path: &Path, + input: SaveEducationAutomationRuleInput, +) -> Result { + validate_entity_id(&input.rule_id, "EDU-AUTO-")?; + validate_entity_id(&input.table_id, "EDU-TABLE-")?; + if input.expected_revision < 1 || input.condition_value.len() > MAX_CELL_BYTES { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_INPUT_INVALID".into()); + } + let title = normalized_title(&input.title, "未命名自动化规则")?; + let operator = normalized_operator(&input.operator)?; + let connection = open_database(path)?; + let table = read_table_with_connection(&connection, &input.table_id)?; + validate_automation_rule_against_table( + &table, + &input.condition_column_id, + &input.action_column_id, + &input.action_value, + )?; + let changed = connection + .execute( + "UPDATE education_automation_rules + SET title = ?1, table_id = ?2, condition_column_id = ?3, operator = ?4, + condition_value = ?5, action_column_id = ?6, action_value = ?7, + enabled = ?8, revision = revision + 1, updated_at_unix_ms = ?9 + WHERE rule_id = ?10 AND revision = ?11 AND archived = 0", + params![ + title, + input.table_id, + input.condition_column_id, + operator, + input.condition_value, + input.action_column_id, + input.action_value, + if input.enabled { 1 } else { 0 }, + now_ms(), + input.rule_id, + input.expected_revision + ], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_RULE_REVISION_CONFLICT".into()); + } + read_automation_rule_with_connection(&connection, &input.rule_id) +} + +fn archive_automation_rule_at( + path: &Path, + rule_id: String, +) -> Result { + validate_entity_id(&rule_id, "EDU-AUTO-")?; + let connection = open_database(path)?; + let changed = connection + .execute( + "UPDATE education_automation_rules + SET archived = 1, updated_at_unix_ms = ?1 + WHERE rule_id = ?2 AND archived = 0", + params![now_ms(), rule_id], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_RULE_NOT_FOUND".into()); + } + Ok(EducationArchiveReceipt { + state: "ARCHIVED_RECOVERABLE", + entity_id: rule_id, + }) +} + +fn rule_matches(operator: &str, condition_value: &str, cell: &str) -> bool { + match operator { + "EQUALS" => cell == condition_value, + "CONTAINS" => cell.contains(condition_value), + "IS_EMPTY" => cell.trim().is_empty(), + "IS_NOT_EMPTY" => !cell.trim().is_empty(), + _ => false, + } +} + +fn automation_changes( + rule: &EducationAutomationRule, + table: &EducationTable, +) -> Result<(usize, usize, Vec), String> { + validate_automation_rule_against_table( + table, + &rule.condition_column_id, + &rule.action_column_id, + &rule.action_value, + )?; + let condition_index = table + .columns + .iter() + .position(|column| column.column_id == rule.condition_column_id) + .ok_or_else(|| "HOLOLAKE_EDUCATION_AUTOMATION_COLUMN_INVALID".to_string())?; + let action_index = table + .columns + .iter() + .position(|column| column.column_id == rule.action_column_id) + .ok_or_else(|| "HOLOLAKE_EDUCATION_AUTOMATION_COLUMN_INVALID".to_string())?; + let mut matched_rows = 0; + let mut changed_cells = 0; + let mut rows = table.rows.clone(); + for row in &mut rows { + if rule_matches( + &rule.operator, + &rule.condition_value, + &row.cells[condition_index], + ) { + matched_rows += 1; + if row.cells[action_index] != rule.action_value { + row.cells[action_index] = rule.action_value.clone(); + changed_cells += 1; + } + } + } + Ok((matched_rows, changed_cells, rows)) +} + +fn automation_preview_token( + rule: &EducationAutomationRule, + table: &EducationTable, + matched_rows: usize, + changed_cells: usize, +) -> String { + let material = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + rule.rule_id, + rule.revision, + table.table_id, + table.revision, + rule.condition_column_id, + rule.operator, + rule.condition_value, + rule.action_column_id, + rule.action_value, + matched_rows, + changed_cells + ); + digest(&SHA256, material.as_bytes()) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn preview_automation_rule_with_connection( + connection: &Connection, + input: &PreviewEducationAutomationRuleInput, +) -> Result< + ( + EducationAutomationPreview, + EducationAutomationRule, + EducationTable, + Vec, + ), + String, +> { + let rule = read_automation_rule_with_connection(connection, &input.rule_id)?; + if !rule.enabled { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_RULE_DISABLED".into()); + } + let table = read_table_with_connection(connection, &rule.table_id)?; + if rule.revision != input.expected_rule_revision + || table.revision != input.expected_table_revision + { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_STALE".into()); + } + let (matched_rows, changed_cells, rows) = automation_changes(&rule, &table)?; + let preview_token = automation_preview_token(&rule, &table, matched_rows, changed_cells); + let preview = EducationAutomationPreview { + state: "PREVIEW_ONLY_NO_WRITE", + rule_id: rule.rule_id.clone(), + table_id: table.table_id.clone(), + rule_revision: rule.revision, + table_revision: table.revision, + matched_rows, + changed_cells, + preview_token, + }; + Ok((preview, rule, table, rows)) +} + +fn preview_automation_rule_at( + path: &Path, + input: PreviewEducationAutomationRuleInput, +) -> Result { + let connection = open_database(path)?; + preview_automation_rule_with_connection(&connection, &input).map(|result| result.0) +} + +fn execute_automation_rule_at( + path: &Path, + input: ExecuteEducationAutomationRuleInput, +) -> Result { + if input.preview_token.len() != 64 + || !input + .preview_token + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_TOKEN_INVALID".into()); + } + let mut connection = open_database(path)?; + let transaction = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let preview_input = PreviewEducationAutomationRuleInput { + rule_id: input.rule_id.clone(), + expected_rule_revision: input.expected_rule_revision, + expected_table_revision: input.expected_table_revision, + }; + let (preview, _rule, table, rows) = + preview_automation_rule_with_connection(&transaction, &preview_input)?; + if preview.preview_token != input.preview_token { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_TOKEN_MISMATCH".into()); + } + let now = now_ms(); + let to_revision = if preview.changed_cells > 0 { + table.revision + 1 + } else { + table.revision + }; + if preview.changed_cells > 0 { + let rows_json = serde_json::to_string(&rows).map_err(json_write_error)?; + let changed = transaction + .execute( + "UPDATE education_tables SET rows_json = ?1, revision = revision + 1, + updated_at_unix_ms = ?2 + WHERE table_id = ?3 AND revision = ?4 AND archived = 0", + params![rows_json, now, table.table_id, table.revision], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_AUTOMATION_TABLE_REVISION_CONFLICT".into()); + } + } + let run_id = format!("EDU-RUN-{}", Uuid::new_v4()); + transaction + .execute( + "INSERT INTO education_automation_runs( + run_id, rule_id, table_id, matched_rows, changed_cells, + from_table_revision, to_table_revision, ran_at_unix_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + run_id, + preview.rule_id, + preview.table_id, + preview.matched_rows as i64, + preview.changed_cells as i64, + table.revision, + to_revision, + now + ], + ) + .map_err(database_write_error)?; + transaction.commit().map_err(database_write_error)?; + Ok(EducationAutomationRunReceipt { + state: if preview.changed_cells > 0 { + "APPLIED" + } else { + "UNCHANGED" + } + .into(), + run_id, + rule_id: input.rule_id, + table_id: table.table_id, + matched_rows: preview.matched_rows, + changed_cells: preview.changed_cells, + from_table_revision: table.revision, + to_table_revision: to_revision, + ran_at_unix_ms: now, + }) +} + +fn archive_at( + path: &Path, + table: &str, + id_column: &str, + entity_id: String, +) -> Result { + if !matches!( + (table, id_column), + ("education_documents", "document_id") | ("education_tables", "table_id") + ) { + return Err("HOLOLAKE_EDUCATION_ARCHIVE_TARGET_INVALID".into()); + } + let expected_prefix = if table == "education_documents" { + "EDU-DOC-" + } else { + "EDU-TABLE-" + }; + validate_entity_id(&entity_id, expected_prefix)?; + let connection = open_database(path)?; + let sql = format!( + "UPDATE {table} SET archived = 1, updated_at_unix_ms = ?1 + WHERE {id_column} = ?2 AND archived = 0" + ); + let changed = connection + .execute(&sql, params![now_ms(), entity_id]) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_EDUCATION_ARCHIVE_NOT_FOUND".into()); + } + Ok(EducationArchiveReceipt { + state: "ARCHIVED_RECOVERABLE", + entity_id, + }) +} + +pub(crate) fn validate_table_data( + columns: &[EducationTableColumn], + rows: &[EducationTableRow], +) -> Result<(), String> { + if columns.is_empty() || columns.len() > MAX_TABLE_COLUMNS || rows.len() > MAX_TABLE_ROWS { + return Err("HOLOLAKE_EDUCATION_TABLE_BOUNDS_INVALID".into()); + } + let mut column_ids = HashSet::new(); + for column in columns { + if !column_ids.insert(column.column_id.as_str()) + || column.column_id.len() > 80 + || column.title.trim().is_empty() + || column.title.len() > MAX_TITLE_BYTES + { + return Err("HOLOLAKE_EDUCATION_TABLE_COLUMN_INVALID".into()); + } + } + let mut row_ids = HashSet::new(); + for row in rows { + if !row_ids.insert(row.row_id.as_str()) + || row.row_id.len() > 80 + || row.cells.len() != columns.len() + || row.cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES) + { + return Err("HOLOLAKE_EDUCATION_TABLE_ROW_INVALID".into()); + } + } + Ok(()) +} + +fn normalized_title(value: &str, fallback: &str) -> Result { + let title = if value.trim().is_empty() { + fallback + } else { + value.trim() + }; + if title.len() > MAX_TITLE_BYTES + || title + .chars() + .any(|character| matches!(character, '\0' | '\r' | '\n')) + { + return Err("HOLOLAKE_EDUCATION_TITLE_INVALID".into()); + } + Ok(title.to_string()) +} + +fn validate_entity_id(value: &str, prefix: &str) -> Result<(), String> { + if value.starts_with(prefix) + && value.len() <= 80 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + Ok(()) + } else { + Err("HOLOLAKE_EDUCATION_ENTITY_ID_INVALID".into()) + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} + +fn decode_sqlite_json(value: &str, column: usize) -> rusqlite::Result { + serde_json::from_str(value).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(column, Type::Text, Box::new(error)) + }) +} + +fn database_read_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_EDUCATION_DATABASE_READ_FAILED: {error}") +} + +fn database_write_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_EDUCATION_DATABASE_WRITE_FAILED: {error}") +} + +fn json_write_error(error: serde_json::Error) -> String { + format!("HOLOLAKE_EDUCATION_TABLE_JSON_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn documents_persist_across_reopen_and_reject_stale_revisions() { + let directory = tempdir().unwrap(); + let database = directory.path().join("education.sqlite3"); + let created = create_document_at( + &database, + CreateEducationItemInput { + title: "师训计划".into(), + }, + ) + .unwrap(); + let saved = save_document_at( + &database, + SaveEducationDocumentInput { + document_id: created.document_id.clone(), + title: created.title.clone(), + body: "# 第一周\n\n完成课程准备。".into(), + expected_revision: created.revision, + }, + ) + .unwrap(); + assert_eq!(saved.revision, 2); + assert_eq!( + read_document_at(&database, &created.document_id) + .unwrap() + .body, + "# 第一周\n\n完成课程准备。" + ); + assert!(save_document_at( + &database, + SaveEducationDocumentInput { + document_id: created.document_id, + title: "错误覆盖".into(), + body: "旧修订".into(), + expected_revision: 1, + }, + ) + .unwrap_err() + .contains("REVISION_CONFLICT")); + } + + #[test] + fn tables_persist_cells_and_archive_without_hard_delete() { + let directory = tempdir().unwrap(); + let database = directory.path().join("education.sqlite3"); + let created = create_table_at( + &database, + CreateEducationItemInput { + title: "学员进度".into(), + }, + ) + .unwrap(); + let row = EducationTableRow { + row_id: format!("ROW-{}", Uuid::new_v4()), + cells: vec!["张同学".into(), "学员".into(), "进行中".into()], + }; + let saved = save_table_at( + &database, + SaveEducationTableInput { + table_id: created.table_id.clone(), + title: created.title, + columns: created.columns, + rows: vec![row], + expected_revision: created.revision, + }, + ) + .unwrap(); + assert_eq!(saved.rows[0].cells[0], "张同学"); + archive_at( + &database, + "education_tables", + "table_id", + created.table_id.clone(), + ) + .unwrap(); + assert!(read_table_at(&database, &created.table_id).is_err()); + let connection = open_database(&database).unwrap(); + let archived: i64 = connection + .query_row( + "SELECT archived FROM education_tables WHERE table_id = ?1", + [created.table_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(archived, 1); + } + + #[test] + fn imported_tables_stay_unassigned_until_a_human_routes_them_to_education() { + let directory = tempdir().unwrap(); + let database = directory.path().join("education.sqlite3"); + let imported = ImportedEducationTable { + title: "待识别业务数据".into(), + columns: vec![EducationTableColumn { + column_id: format!("COL-{}", Uuid::new_v4()), + title: "业务字段".into(), + }], + rows: vec![EducationTableRow { + row_id: format!("ROW-{}", Uuid::new_v4()), + cells: vec!["尚未归属教育".into()], + }], + }; + let (_, tables) = import_table_batch_at( + &database, + vec![imported], + EducationImportRegistration { + source_filename: "业务数据.xlsx".into(), + source_format: "XLSX".into(), + source_sha256: "0".repeat(64), + profile_json: "{}".into(), + imported_at_unix_ms: now_ms(), + }, + ) + .unwrap(); + let table_id = tables[0].table_id.clone(); + let quarantined = snapshot_at(&database).unwrap(); + assert_eq!(quarantined.table_count, 0); + assert_eq!(quarantined.unassigned_count, 1); + assert_eq!(quarantined.unassigned_tables[0].table_id, table_id); + + assign_imported_table_to_education_at(&database, &table_id).unwrap(); + let assigned = snapshot_at(&database).unwrap(); + assert_eq!(assigned.table_count, 1); + assert_eq!(assigned.unassigned_count, 0); + } + + #[test] + fn table_bounds_are_enforced() { + let columns = (0..=MAX_TABLE_COLUMNS) + .map(|index| EducationTableColumn { + column_id: format!("COL-{index}"), + title: format!("列 {index}"), + }) + .collect::>(); + assert!(validate_table_data(&columns, &[]).is_err()); + } + + fn seeded_automation_table(database: &Path) -> EducationTable { + let created = create_table_at( + database, + CreateEducationItemInput { + title: "学员跟进".into(), + }, + ) + .unwrap(); + save_table_at( + database, + SaveEducationTableInput { + table_id: created.table_id.clone(), + title: created.title, + columns: created.columns, + rows: vec![ + EducationTableRow { + row_id: format!("ROW-{}", Uuid::new_v4()), + cells: vec!["林同学".into(), "学员".into(), "待跟进".into()], + }, + EducationTableRow { + row_id: format!("ROW-{}", Uuid::new_v4()), + cells: vec!["周老师".into(), "教师".into(), "已完成".into()], + }, + ], + expected_revision: created.revision, + }, + ) + .unwrap() + } + + #[test] + fn automation_rules_persist_preview_execute_and_receipt() { + let directory = tempdir().unwrap(); + let database = directory.path().join("education.sqlite3"); + let table = seeded_automation_table(&database); + let rule = create_automation_rule_at( + &database, + CreateEducationAutomationRuleInput { + title: "待跟进转为已联系".into(), + table_id: table.table_id.clone(), + condition_column_id: table.columns[2].column_id.clone(), + operator: "EQUALS".into(), + condition_value: "待跟进".into(), + action_column_id: table.columns[2].column_id.clone(), + action_value: "已联系".into(), + }, + ) + .unwrap(); + let preview = preview_automation_rule_at( + &database, + PreviewEducationAutomationRuleInput { + rule_id: rule.rule_id.clone(), + expected_rule_revision: rule.revision, + expected_table_revision: table.revision, + }, + ) + .unwrap(); + assert_eq!(preview.matched_rows, 1); + assert_eq!(preview.changed_cells, 1); + assert_eq!(snapshot_at(&database).unwrap().automation_rule_count, 1); + let receipt = execute_automation_rule_at( + &database, + ExecuteEducationAutomationRuleInput { + rule_id: rule.rule_id, + expected_rule_revision: rule.revision, + expected_table_revision: table.revision, + preview_token: preview.preview_token, + }, + ) + .unwrap(); + assert_eq!(receipt.state, "APPLIED"); + assert_eq!(receipt.to_table_revision, table.revision + 1); + let updated = read_table_at(&database, &table.table_id).unwrap(); + assert_eq!(updated.rows[0].cells[2], "已联系"); + assert_eq!( + snapshot_at(&database).unwrap().recent_automation_runs.len(), + 1 + ); + } + + #[test] + fn automation_execution_rejects_stale_or_changed_preview() { + let directory = tempdir().unwrap(); + let database = directory.path().join("education.sqlite3"); + let table = seeded_automation_table(&database); + let rule = create_automation_rule_at( + &database, + CreateEducationAutomationRuleInput { + title: "教师标记".into(), + table_id: table.table_id.clone(), + condition_column_id: table.columns[1].column_id.clone(), + operator: "EQUALS".into(), + condition_value: "教师".into(), + action_column_id: table.columns[2].column_id.clone(), + action_value: "已确认".into(), + }, + ) + .unwrap(); + let preview = preview_automation_rule_at( + &database, + PreviewEducationAutomationRuleInput { + rule_id: rule.rule_id.clone(), + expected_rule_revision: rule.revision, + expected_table_revision: table.revision, + }, + ) + .unwrap(); + let mut wrong_token = preview.preview_token; + wrong_token.replace_range(0..1, if &wrong_token[0..1] == "0" { "1" } else { "0" }); + assert!(execute_automation_rule_at( + &database, + ExecuteEducationAutomationRuleInput { + rule_id: rule.rule_id, + expected_rule_revision: rule.revision, + expected_table_revision: table.revision, + preview_token: wrong_token, + }, + ) + .unwrap_err() + .contains("TOKEN_MISMATCH")); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index 5c3ba1b2e..b83092ca0 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -7,6 +7,8 @@ mod code_repo_login; mod direct_local_broker; mod direct_local_session; mod dynamic_capability_routing; +mod education_translation; +mod education_workspace; mod enterprise_work_channel; mod glp_envelope; mod gls_bootstrap_compiler; diff --git a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs index d7f8ee9ef..50b73f42c 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs @@ -45,6 +45,13 @@ const PERSONA_CHANNEL_BODY_PACKAGE: &[u8] = include_bytes!( const PERSONA_CHANNEL_BODY_SIGNATURE: &str = include_str!( "../../fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig" ); +const EDUCATION_WORKBENCH_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001"; +const EDUCATION_WORKBENCH_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod" +); +const EDUCATION_WORKBENCH_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod.sig" +); struct BundledModuleSource { module_number: &'static str, @@ -68,6 +75,11 @@ const BUNDLED_MODULES: &[BundledModuleSource] = &[ package: PERSONA_CHANNEL_BODY_PACKAGE, signature: PERSONA_CHANNEL_BODY_SIGNATURE, }, + BundledModuleSource { + module_number: EDUCATION_WORKBENCH_NUMBER, + package: EDUCATION_WORKBENCH_PACKAGE, + signature: EDUCATION_WORKBENCH_SIGNATURE, + }, ]; #[derive(Debug, Deserialize)] @@ -1513,7 +1525,7 @@ mod tests { assert_eq!(package.manifest.adapter, "channel-workbench-v1"); assert_eq!(package.manifest.permissions.len(), 4); assert!(is_sha256(&digest)); - assert_eq!(BUNDLED_MODULES.len(), 3); + assert_eq!(BUNDLED_MODULES.len(), 4); } #[test] @@ -1534,6 +1546,25 @@ mod tests { assert!(is_sha256(&digest)); } + #[test] + fn bundled_education_workbench_is_an_official_signed_module() { + let (_, package, digest) = read_verified_package_bytes( + EDUCATION_WORKBENCH_PACKAGE, + EDUCATION_WORKBENCH_SIGNATURE, + RELEASE_TRUST_RAW, + ) + .unwrap(); + assert_eq!(package.manifest.module_number, EDUCATION_WORKBENCH_NUMBER); + assert_eq!(package.manifest.registration_class, "OFFICIAL_LIGHTHOUSE"); + assert_eq!(package.manifest.adapter, "education-workbench-v1"); + assert_eq!(package.manifest.permissions.len(), 8); + assert_eq!( + package.payload["adapterConfig"]["importDefaultScope"], + "UNASSIGNED" + ); + assert!(is_sha256(&digest)); + } + #[cfg(unix)] #[test] fn symlinked_package_input_is_rejected_before_signature_processing() { diff --git a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs index 55228ec0e..538d717f3 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs @@ -53,7 +53,7 @@ fn validate_tree() -> Result<(), String> { || tree.record_id != "HLP-UNIFIED-NUMBER-TREE-001" || tree.state != "MACHINE_COMPILED_STARTUP_ENFORCED" || tree.root_number != "HLP-NUMBER-WORLD-ROOT-001" - || tree.route_count != 106 + || tree.route_count != 124 || tree.routes.len() != tree.route_count || !tree.invariants.number_is_stable_coordinate_not_authority || !tree.invariants.path_is_unique_navigation diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs index 11698c21e..e502e4ab6 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs @@ -934,7 +934,7 @@ mod tests { #[test] fn registry_is_closed_and_contains_every_migrated_command() { let registry = load_registry().unwrap(); - assert_eq!(registry.operations.len(), 84); + assert_eq!(registry.operations.len(), 102); assert!(!registry.runtime.legacy_direct_commands_allowed); } diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs index 58b5cf6b0..814544c56 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs @@ -167,6 +167,67 @@ pub(crate) async fn dispatch( "channel_growth::update_channel_growth_sharing" => { json(crate::channel_growth::update_channel_growth_sharing(app, input(&payload)?).await?) } + "education_workspace::get_education_workspace_snapshot" => { + json(crate::education_workspace::get_education_workspace_snapshot(app).await?) + } + "education_workspace::create_education_document" => json( + crate::education_workspace::create_education_document(app, input(&payload)?).await?, + ), + "education_workspace::read_education_document" => { + json(crate::education_workspace::read_education_document(app, input(&payload)?).await?) + } + "education_workspace::save_education_document" => { + json(crate::education_workspace::save_education_document(app, input(&payload)?).await?) + } + "education_workspace::archive_education_document" => json( + crate::education_workspace::archive_education_document(app, input(&payload)?).await?, + ), + "education_workspace::create_education_table" => { + json(crate::education_workspace::create_education_table(app, input(&payload)?).await?) + } + "education_workspace::read_education_table" => { + json(crate::education_workspace::read_education_table(app, input(&payload)?).await?) + } + "education_workspace::save_education_table" => { + json(crate::education_workspace::save_education_table(app, input(&payload)?).await?) + } + "education_workspace::archive_education_table" => { + json(crate::education_workspace::archive_education_table(app, input(&payload)?).await?) + } + "education_workspace::assign_imported_table_to_education" => json( + crate::education_workspace::assign_imported_table_to_education(app, input(&payload)?) + .await?, + ), + "education_translation::import_education_tables_from_dialog" => { + json(crate::education_translation::import_education_tables_from_dialog(app).await?) + } + "education_translation::export_education_table_to_dialog" => json( + crate::education_translation::export_education_table_to_dialog(app, input(&payload)?) + .await?, + ), + "education_translation::get_education_recognition_capability" => { + json(crate::education_translation::get_education_recognition_capability(app)?) + } + "education_workspace::create_education_automation_rule" => json( + crate::education_workspace::create_education_automation_rule(app, input(&payload)?) + .await?, + ), + "education_workspace::save_education_automation_rule" => json( + crate::education_workspace::save_education_automation_rule(app, input(&payload)?) + .await?, + ), + "education_workspace::archive_education_automation_rule" => json( + crate::education_workspace::archive_education_automation_rule(app, input(&payload)?) + .await?, + ), + "education_workspace::preview_education_automation_rule" => json( + crate::education_workspace::preview_education_automation_rule(app, input(&payload)?) + .await?, + ), + "education_workspace::execute_education_automation_rule" => json( + crate::education_workspace::execute_education_automation_rule(app, input(&payload)?) + .await?, + ), "local_development_bridge::acquire_development_write_lane" => json( crate::local_development_bridge::acquire_development_write_lane(app, input(&payload)?) .await?, diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 52e0add28..520f5a03c 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -7,6 +7,7 @@ import type { ChannelWorkbenchSnapshot } from './modules/channel-workbench' const ChannelWorkbenchStudio = lazy(() => import('./modules/channel-workbench').then((module) => ({ default: module.ChannelWorkbenchStudio }))) const PersonaChannelBody = lazy(() => import('./modules/persona-channel-body').then((module) => ({ default: module.PersonaChannelBody }))) +const EducationWorkspace = lazy(() => import('./modules/education-workspace').then((module) => ({ default: module.EducationWorkspace }))) const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] @@ -53,7 +54,7 @@ import './design-tokens.css' import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' -type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'persona' | 'code' | 'receipts' | 'system' +type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'education' | 'persona' | 'code' | 'receipts' | 'system' type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'personalNodeGuide' | 'tool' type KnowledgeSource = 'native' | 'legacy' @@ -367,7 +368,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] -const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } +const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', education: '教育工作台', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } const domainGates = [ { domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · ONLINE', facts: [ ['域标识', 'BRANCH_DOMAIN'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], @@ -607,6 +608,9 @@ function HoloLakeApp() { const [personaBodyModule, setPersonaBodyModule] = useState(null) const [personaBodyBusy, setPersonaBodyBusy] = useState(false) const [personaBodyMessage, setPersonaBodyMessage] = useState('') + const [educationModule, setEducationModule] = useState(null) + const [educationBusy, setEducationBusy] = useState(false) + const [educationMessage, setEducationMessage] = useState('') const [expanded, setExpanded] = useState>(() => new Set(['导入'])) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -1157,6 +1161,27 @@ function HoloLakeApp() { finally { setPersonaBodyBusy(false) } } + const refreshEducationModule = async () => { + try { + const catalog = await invoke('get_bundled_module_catalog') + const module = catalog.find((item) => item.moduleNumber === 'HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001') || null + setEducationModule(module) + return module + } catch (error) { setEducationMessage(humanError(error, 'system')); return null } + } + const openEducation = () => { openWorldTool('education'); void refreshEducationModule() } + const activateEducationModule = async () => { + setEducationBusy(true) + setEducationMessage('正在验证官方签名、登记模块编号、确认八项边界并执行自检……') + try { + await invoke('activate_bundled_module', { input: { moduleNumber: 'HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001', humanConfirmedPermissionExpansion: true } }) + const module = await refreshEducationModule() + if (!module || module.installedState !== 'ACTIVE') throw new Error('HOLOLAKE_MODULE_NOT_ACTIVE') + setEducationMessage('教育工作台已通过签名、编号、权限和自检验收。') + } catch (error) { setEducationMessage(humanError(error, 'system')) } + finally { setEducationBusy(false) } + } + const cloneCodeChannel = async (event: React.FormEvent) => { event.preventDefault() if (!cloneUrl.trim()) return @@ -1557,6 +1582,20 @@ function HoloLakeApp() { ) + const renderEducation = () => ( +
+ {educationModule?.installedState === 'ACTIVE' + ?

正在装载教育工作台……

}> setWorldStage(toolReturnStage)}/>
+ :
+

启用教育工作台

+

模块复用已验收的文档与真实单元格引擎,并增加账号隔离存储、外部表格翻译、人工归属、显式数据清理和预览后执行的自动化。

+ HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001 · 8 项编号权限 + + {educationMessage &&

{educationMessage}

} +
} +
+ ) + const renderCode = () => (
} @@ -1839,7 +1879,7 @@ function HoloLakeApp() { } {worldStage === 'tool' &&
{viewLabels[view]}{domainDisplayName(repoLogin.domain)}
-
{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'persona' ? renderPersonaBody() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}
+
{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'education' ? renderEducation() : view === 'persona' ? renderPersonaBody() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}
}
光湖语言系统 · 通用人工智能操作平台GH-AIOS
diff --git a/product-source/hololake-native-desktop/src/modules/education-workspace/education-adaptive-rendering.ts b/product-source/hololake-native-desktop/src/modules/education-workspace/education-adaptive-rendering.ts new file mode 100644 index 000000000..a2e168f90 --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/education-workspace/education-adaptive-rendering.ts @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const EDUCATION_EDITABLE_CELL_BUDGET = 240 + +export type EducationAdaptiveView = 'grid' | 'cards' | 'board' | 'dashboard' + +export interface EducationAdaptiveInputs { + rowCount: number + columnCount: number + numericColumnCount: number + groupableColumnCount: number + focusConfidence: 'high' | 'medium' | 'low' +} + +export interface EducationModelRenderProposal { + primaryFocus: string + groupColumnIndex?: number + measureColumnIndex?: number + views: EducationAdaptiveView[] + pageSize?: number +} + +export interface EducationAdaptiveRenderPlan { + source: 'LOCAL_BASELINE' | 'MODEL_PROPOSAL' + state: 'READY' | 'DEGRADED' | 'WAITING_HUMAN_CONFIRMATION' | 'MODEL_PROPOSAL_REJECTED' + views: EducationAdaptiveView[] + pageSize: number + primaryFocus: string + reasons: string[] +} + +export function educationEditablePageSize(columnCount: number): number { + const boundedColumns = Math.max(1, Math.min(30, columnCount)) + return Math.max(5, Math.min(20, Math.floor(EDUCATION_EDITABLE_CELL_BUDGET / boundedColumns))) +} + +export function buildEducationBaselineRenderPlan(inputs: EducationAdaptiveInputs): EducationAdaptiveRenderPlan { + const reasons: string[] = [] + const views: EducationAdaptiveView[] = ['grid', 'cards'] + if (inputs.groupableColumnCount > 0) views.push('board') + else reasons.push('没有可靠分类字段,分类看板已降级停用。') + if (inputs.numericColumnCount > 0) views.push('dashboard') + else reasons.push('没有可靠数值字段,数据概览降级为计数摘要。') + if (inputs.focusConfidence === 'low') reasons.push('重点置信度不足,保留人工选择,不自动突出字段。') + if (inputs.rowCount * inputs.columnCount > EDUCATION_EDITABLE_CELL_BUDGET) reasons.push('可编辑单元格超过本机安全预算,已启用动态分页。') + return { + source: 'LOCAL_BASELINE', + state: reasons.length ? 'DEGRADED' : 'READY', + views, + pageSize: educationEditablePageSize(inputs.columnCount), + primaryFocus: inputs.focusConfidence === 'low' ? '重点待选择' : '本地基础算法重点', + reasons, + } +} + +export function validateEducationModelRenderProposal( + inputs: EducationAdaptiveInputs, + proposal: EducationModelRenderProposal, + humanConfirmed: boolean, +): EducationAdaptiveRenderPlan { + const baseline = buildEducationBaselineRenderPlan(inputs) + const reasons: string[] = [] + const uniqueViews = [...new Set(proposal.views)] + if (!proposal.primaryFocus.trim() || proposal.primaryFocus.length > 120) reasons.push('模型建议的重点标题无效。') + if (!uniqueViews.length || uniqueViews.some((view) => !['grid', 'cards', 'board', 'dashboard'].includes(view))) reasons.push('模型建议包含未登记视图。') + if (proposal.groupColumnIndex !== undefined && (proposal.groupColumnIndex < 0 || proposal.groupColumnIndex >= inputs.columnCount)) reasons.push('模型建议的分类字段不存在。') + if (proposal.measureColumnIndex !== undefined && (proposal.measureColumnIndex < 0 || proposal.measureColumnIndex >= inputs.columnCount)) reasons.push('模型建议的统计字段不存在。') + const requestedPageSize = proposal.pageSize ?? baseline.pageSize + const maximumSafePageSize = educationEditablePageSize(inputs.columnCount) + if (!Number.isInteger(requestedPageSize) || requestedPageSize < 5 || requestedPageSize > maximumSafePageSize) reasons.push('模型建议的页面密度超过本机安全预算。') + if (reasons.length) return { ...baseline, state: 'MODEL_PROPOSAL_REJECTED', reasons: [...reasons, '已优雅退回本地基础算法。'] } + if (!humanConfirmed) return { ...baseline, state: 'WAITING_HUMAN_CONFIRMATION', reasons: ['模型建议已通过本地校验,等待用户确认后启用。'] } + return { + source: 'MODEL_PROPOSAL', + state: 'READY', + views: uniqueViews, + pageSize: requestedPageSize, + primaryFocus: proposal.primaryFocus.trim(), + reasons: ['模型建议已通过本地结构校验、资源上限与用户确认。'], + } +} diff --git a/product-source/hololake-native-desktop/src/modules/education-workspace/education-data.ts b/product-source/hololake-native-desktop/src/modules/education-workspace/education-data.ts new file mode 100644 index 000000000..264a28185 --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/education-workspace/education-data.ts @@ -0,0 +1,75 @@ +export interface EducationDataColumn { columnId: string; title: string } +export interface EducationDataRow { rowId: string; cells: string[] } +export interface EducationDataTable { columns: EducationDataColumn[]; rows: EducationDataRow[] } + +export const EDUCATION_CLEANUP_CHANGE_PREVIEW_LIMIT = 12 + +export type EducationDataChangeKind = 'TRIM_CELL' | 'REMOVE_EMPTY_ROW' | 'REMOVE_DUPLICATE_ROW' + +export interface EducationDataChangePreview { + kind: EducationDataChangeKind + rowNumber: number + columnTitle: string + before: string + after: string +} + +export interface EducationDataAnalysis { + originalRows: number + cleanedRows: EducationDataRow[] + emptyRows: number + duplicateRows: number + trimmedCells: number + changeCount: number + changePreview: EducationDataChangePreview[] +} + +function previewCell(value: string): string { + const singleLine = value.replace(/\t/g, '⇥').replace(/\r?\n/g, '↵').replace(/ /g, '·') + return singleLine.length > 120 ? `${singleLine.slice(0, 117)}…` : singleLine +} + +export function analyzeEducationTableData(table: EducationDataTable): EducationDataAnalysis { + let emptyRows = 0 + let duplicateRows = 0 + let trimmedCells = 0 + let changeCount = 0 + const seen = new Set() + const cleanedRows: EducationDataRow[] = [] + const changePreview: EducationDataChangePreview[] = [] + const addPreview = (change: EducationDataChangePreview) => { + changeCount += 1 + if (changePreview.length < EDUCATION_CLEANUP_CHANGE_PREVIEW_LIMIT) changePreview.push(change) + } + for (const [rowIndex, row] of table.rows.entries()) { + const cells = table.columns.map((_, index) => { + const original = row.cells[index] || '' + const cleaned = original.trim() + if (cleaned !== original) { + trimmedCells += 1 + addPreview({ + kind: 'TRIM_CELL', + rowNumber: rowIndex + 1, + columnTitle: table.columns[index]?.title || `字段 ${index + 1}`, + before: previewCell(original), + after: previewCell(cleaned), + }) + } + return cleaned + }) + if (cells.every((cell) => !cell)) { + emptyRows += 1 + addPreview({ kind: 'REMOVE_EMPTY_ROW', rowNumber: rowIndex + 1, columnTitle: '整行', before: '完整空行', after: '移除' }) + continue + } + const signature = JSON.stringify(cells) + if (seen.has(signature)) { + duplicateRows += 1 + addPreview({ kind: 'REMOVE_DUPLICATE_ROW', rowNumber: rowIndex + 1, columnTitle: '整行', before: '与前文完全重复', after: '移除' }) + continue + } + seen.add(signature) + cleanedRows.push({ ...row, cells }) + } + return { originalRows: table.rows.length, cleanedRows, emptyRows, duplicateRows, trimmedCells, changeCount, changePreview } +} diff --git a/product-source/hololake-native-desktop/src/modules/education-workspace/education-document-engine.tsx b/product-source/hololake-native-desktop/src/modules/education-workspace/education-document-engine.tsx new file mode 100644 index 000000000..ef06014c6 --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/education-workspace/education-document-engine.tsx @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import ChannelDocumentEngine from '../channel-workbench/document-engine' + +export default function EducationDocumentEngine({ body, onChange }: { body: string; onChange: (body: string) => void }) { + return +} diff --git a/product-source/hololake-native-desktop/src/modules/education-workspace/index.tsx b/product-source/hololake-native-desktop/src/modules/education-workspace/index.tsx new file mode 100644 index 000000000..40b82d5d2 --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/education-workspace/index.tsx @@ -0,0 +1,169 @@ +import { lazy, Suspense, useEffect, useMemo, useState } from 'react' +import { numberedInvoke as invoke } from '../numbered-ipc' +import { analyzeEducationTableData, type EducationDataAnalysis } from './education-data' +import './styles.css' + +const EducationDocumentEngine = lazy(() => import('./education-document-engine')) +const ChannelSpreadsheetEngine = lazy(() => import('../channel-workbench/spreadsheet-engine')) + +export const EDUCATION_MODULE_NUMBER = 'HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001' + +interface EducationColumn { columnId: string; title: string } +interface EducationRow { rowId: string; cells: string[] } +interface DocumentSummary { documentId: string; title: string; revision: number; updatedAtUnixMs: number } +interface TableSummary { tableId: string; title: string; revision: number; columnCount: number; rowCount: number; updatedAtUnixMs: number } +interface UnassignedTableSummary { tableId: string; title: string; columnCount: number; rowCount: number; importedAtUnixMs: number } +interface EducationDocument extends DocumentSummary { body: string; createdAtUnixMs: number } +interface EducationTable extends TableSummary { columns: EducationColumn[]; rows: EducationRow[]; createdAtUnixMs: number } +interface AutomationRule { + ruleId: string; title: string; tableId: string; conditionColumnId: string; operator: string; conditionValue: string + actionColumnId: string; actionValue: string; enabled: boolean; revision: number +} +interface AutomationPreview { state: string; ruleId: string; tableId: string; ruleRevision: number; tableRevision: number; matchedRows: number; changedCells: number; previewToken: string } +interface EducationSnapshot { + schema: string; state: string; documents: DocumentSummary[]; tables: TableSummary[]; automationRules: AutomationRule[] + recentAutomationRuns: Array<{ runId: string; state: string; changedCells: number }> + recentImports: Array<{ importId: string; sourceFilename: string; sourceFormat: string; tableCount: number }> + unassignedTables: UnassignedTableSummary[]; documentCount: number; tableCount: number; automationRuleCount: number; unassignedCount: number +} +interface RecognitionCapability { state: string; modelApiState: string; fileTransferDefault: string; deterministicAdapterIds: string[] } +type Mode = 'overview' | 'documents' | 'tables' | 'cleanup' | 'automation' | 'import' + +function cleanError(error: unknown) { return String(error).replace(/^Error:\s*/, '') } +function localTime(value: number) { return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) } + +export function EducationWorkspace({ onBack }: { onBack: () => void }) { + const [snapshot, setSnapshot] = useState(null) + const [capability, setCapability] = useState(null) + const [mode, setMode] = useState('overview') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [document, setDocument] = useState(null) + const [documentTitle, setDocumentTitle] = useState('') + const [documentBody, setDocumentBody] = useState('') + const [table, setTable] = useState(null) + const [tableTitle, setTableTitle] = useState('') + const [tableValue, setTableValue] = useState<{ columns: EducationColumn[]; rows: EducationRow[] }>({ columns: [], rows: [] }) + const [cleanup, setCleanup] = useState(null) + const [rule, setRule] = useState(null) + const [preview, setPreview] = useState(null) + + const refresh = async () => { + const [nextSnapshot, nextCapability] = await Promise.all([ + invoke('get_education_workspace_snapshot'), + invoke('get_education_recognition_capability'), + ]) + setSnapshot(nextSnapshot) + setCapability(nextCapability) + return nextSnapshot + } + + useEffect(() => { void refresh().catch((error) => setMessage(cleanError(error))) }, []) + + const run = async (action: () => Promise) => { + setBusy(true); setMessage('') + try { await action() } catch (error) { setMessage(cleanError(error)) } finally { setBusy(false) } + } + + const openDocument = (documentId: string) => run(async () => { + const next = await invoke('read_education_document', { input: { documentId } }) + setDocument(next); setDocumentTitle(next.title); setDocumentBody(next.body); setMode('documents') + }) + const createDocument = () => run(async () => { + const next = await invoke('create_education_document', { input: { title: '冰朔教育迁移验收' } }) + setDocument(next); setDocumentTitle(next.title); setDocumentBody(next.body); setMode('documents'); await refresh() + }) + const saveDocument = () => document && run(async () => { + const next = await invoke('save_education_document', { input: { documentId: document.documentId, title: documentTitle, body: documentBody, expectedRevision: document.revision } }) + setDocument(next); setDocumentTitle(next.title); setDocumentBody(next.body); setMessage(`文档修订 ${next.revision} 已写入当前账号。`); await refresh() + }) + const archiveDocument = () => document && run(async () => { + await invoke('archive_education_document', { input: { documentId: document.documentId } }); setDocument(null); await refresh(); setMessage('文档已进入可恢复归档。') + }) + + const openTable = (tableId: string, nextMode: Mode = 'tables') => run(async () => { + const next = await invoke('read_education_table', { input: { tableId } }) + setTable(next); setTableTitle(next.title); setTableValue({ columns: next.columns, rows: next.rows }); setCleanup(null); setPreview(null); setMode(nextMode) + }) + const createTable = () => run(async () => { + const next = await invoke('create_education_table', { input: { title: '冰朔教育迁移验收表' } }) + setTable(next); setTableTitle(next.title); setTableValue({ columns: next.columns, rows: next.rows }); setMode('tables'); await refresh() + }) + const addAcceptanceRow = () => { + if (!tableValue.columns.length) return + setTableValue((value) => ({ ...value, rows: [...value.rows, { rowId: `ROW-${crypto.randomUUID()}`, cells: value.columns.map((_, index) => index === 0 ? '冰朔验收' : index === value.columns.length - 1 ? '已迁移' : '教育工作台') }] })) + } + const saveTableValue = (rows = tableValue.rows, success = '表格已保存。') => table && run(async () => { + const next = await invoke('save_education_table', { input: { tableId: table.tableId, title: tableTitle, columns: tableValue.columns, rows, expectedRevision: table.revision } }) + setTable(next); setTableTitle(next.title); setTableValue({ columns: next.columns, rows: next.rows }); setCleanup(null); setPreview(null); setMessage(`${success} 当前修订 ${next.revision}。`); await refresh() + }) + const archiveTable = () => table && run(async () => { + await invoke('archive_education_table', { input: { tableId: table.tableId } }); setTable(null); await refresh(); setMessage('表格已进入可恢复归档。') + }) + + const analyzeCleanup = () => setCleanup(analyzeEducationTableData(tableValue)) + const applyCleanup = () => cleanup && saveTableValue(cleanup.cleanedRows, '清理结果经人工确认后已保存。') + + const createRule = () => table && table.columns.length > 0 && run(async () => { + const next = await invoke('create_education_automation_rule', { input: { + title: '验收状态自动化', tableId: table.tableId, conditionColumnId: table.columns[0].columnId, operator: 'CONTAINS', conditionValue: '冰朔', + actionColumnId: table.columns[table.columns.length - 1].columnId, actionValue: '自动化已验收', + } }) + setRule(next); setPreview(null); setMode('automation'); await refresh(); setMessage('规则已建立,但尚未执行。') + }) + const chooseRule = async (nextRule: AutomationRule) => { + setRule(nextRule); setPreview(null) + if (!table || table.tableId !== nextRule.tableId) await openTable(nextRule.tableId, 'automation') + else setMode('automation') + } + const previewRule = () => rule && table && run(async () => { + const next = await invoke('preview_education_automation_rule', { input: { ruleId: rule.ruleId, expectedRuleRevision: rule.revision, expectedTableRevision: table.revision } }) + setPreview(next); setMessage(`只读预览:命中 ${next.matchedRows} 行,将改变 ${next.changedCells} 个单元格。`) + }) + const executeRule = () => rule && table && preview && run(async () => { + const receipt = await invoke<{ state: string; changedCells: number; toTableRevision: number }>('execute_education_automation_rule', { input: { + ruleId: rule.ruleId, expectedRuleRevision: preview.ruleRevision, expectedTableRevision: preview.tableRevision, previewToken: preview.previewToken, + } }) + setPreview(null); await openTable(table.tableId, 'automation'); await refresh(); setMessage(`自动化 ${receipt.state},改变 ${receipt.changedCells} 个单元格,修订 ${receipt.toTableRevision}。`) + }) + + const importTables = () => run(async () => { + const outcome = await invoke<{ state: string } | null>('import_education_tables_from_dialog') + await refresh(); setMode('import'); setMessage(outcome ? `导入结果:${outcome.state}。数据仍在未归属区,需人工确认。` : '已取消导入。') + }) + const assignTable = (tableId: string) => run(async () => { + await invoke('assign_imported_table_to_education', { input: { tableId } }); await refresh(); setMessage('已由人工确认归入教育频道。') + }) + const exportTable = (format: string) => table && run(async () => { + const result = await invoke<{ state: string; targetFilename: string } | null>('export_education_table_to_dialog', { input: { table: { tableId: table.tableId, title: tableTitle, columns: tableValue.columns, rows: tableValue.rows, revision: table.revision }, format } }) + setMessage(result ? `${result.targetFilename} 已导出。` : '已取消导出。') + }) + + const analysis = useMemo(() => table ? analyzeEducationTableData(tableValue) : null, [table, tableValue]) + if (!snapshot) return

{message || '正在读取当前账号的教育工作台……'}

+ + return
+
OFFICIAL NUMBERED MODULE

教育工作台

文档、表格、清理、导入和自动化都沿唯一编号路由进入当前账号的原生存储。

+ + + {mode === 'overview' &&
+
教育文档{snapshot.documentCount}
+
教育表格{snapshot.tableCount}
+
人工待归属{snapshot.unassignedCount}
+
自动化规则{snapshot.automationRuleCount}只可预览后人工执行
+

最近文档

{snapshot.documents.map((item) => )}
+

最近表格

{snapshot.tables.map((item) => )}
+
} + + {mode === 'documents' &&
{document ? <>
setDocumentTitle(event.target.value)}/>
正在装载文档引擎……

}>
:

选择或新建一份教育文档。

}
} + + {mode === 'tables' &&
{table ? <>
setTableTitle(event.target.value)}/>
正在装载真实单元格引擎……

}>
:

选择或新建一份教育表格。

}
} + + {mode === 'cleanup' &&

人工确认的数据清理

{table && analysis ? <>

{table.title} · {analysis.originalRows} 行。系统不会后台自动修改。

{analysis.trimmedCells}边界空白{analysis.emptyRows}空行{analysis.duplicateRows}重复行
{cleanup &&

变更预览 · 共 {cleanup.changeCount} 项

{cleanup.changePreview.map((item, index) =>

{item.kind} 第 {item.rowNumber} 行 / {item.columnTitle}:{item.before} → {item.after}

)}
} :

请先选择表格。

}
} + + {mode === 'automation' &&

预览后执行

{table && }{rule && table ?

{rule.title}

条件:{rule.operator} “{rule.conditionValue}” · 动作:写入 “{rule.actionValue}”

{preview &&

命中 {preview.matchedRows} 行,将改变 {preview.changedCells} 个单元格。

{preview.previewToken.slice(0, 20)}…
}
:

先在“表格”中打开表格,再建立规则;或选择已有规则。

}
} + + {mode === 'import' &&

外部表格翻译层

本机先识别结构;导入默认进入未归属区,不会自动判断行业。

确定性适配器
{capability?.deterministicAdapterIds.join(' · ')}
模型接口
{capability?.modelApiState}
文件外传
{capability?.fileTransferDefault}

待人工归属

{snapshot.unassignedTables.length ? snapshot.unassignedTables.map((item) =>
{item.title}{item.rowCount} 行 × {item.columnCount} 列
) :

没有待归属数据。

}
} + {message &&

{message}

} +
+} diff --git a/product-source/hololake-native-desktop/src/modules/education-workspace/styles.css b/product-source/hololake-native-desktop/src/modules/education-workspace/styles.css new file mode 100644 index 000000000..543b21559 --- /dev/null +++ b/product-source/hololake-native-desktop/src/modules/education-workspace/styles.css @@ -0,0 +1,8 @@ +.education-world{min-height:100%;padding:28px;color:#eaf3f3;background:radial-gradient(circle at 75% 0,rgba(84,210,190,.13),transparent 36%),linear-gradient(150deg,#0b1216,#111c21 58%,#0b1318)} +.education-world button,.education-world input{font:inherit}.education-hero{display:grid;grid-template-columns:auto 1fr auto;gap:22px;align-items:start}.education-hero>button{background:transparent;border:0;color:#91aaa9;padding:10px}.education-hero span{font-size:11px;letter-spacing:.18em;color:#74d6c5}.education-hero h1{font-size:38px;margin:5px 0}.education-hero p{margin:0;color:#9db1b2}.education-hero aside{display:flex;flex-direction:column;align-items:flex-end;gap:5px}.education-hero aside b{color:#79d9c7}.education-hero aside small{color:#718588;max-width:260px;text-align:right} +.education-tabs{display:flex;gap:8px;margin:24px 0;border-bottom:1px solid #263a3d}.education-tabs button{border:0;background:transparent;color:#8da3a4;padding:12px 15px;border-bottom:2px solid transparent}.education-tabs button.active{color:#eafffb;border-color:#63d5c2} +.education-overview{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.education-overview>article,.education-overview>section,.education-import,.education-action-layout>aside,.education-action-layout>main{background:#121f24;border:1px solid #26393d;border-radius:16px;padding:18px}.education-overview>article{display:flex;flex-direction:column;gap:8px}.education-overview>article strong{font-size:30px}.education-overview button,.education-editor-shell button,.education-action-layout button,.education-import button{border:1px solid #345155;background:#172b30;color:#dffbf6;border-radius:9px;padding:9px 12px}.education-overview>section{grid-column:span 2}.education-overview>section>button{width:100%;display:flex;justify-content:space-between;margin-top:8px;text-align:left}.education-overview small{color:#8ba0a2} +.education-editor-shell{display:grid;grid-template-columns:230px minmax(0,1fr);gap:14px;min-height:650px}.education-editor-shell>aside{display:flex;flex-direction:column;gap:7px;background:#0e191d;border:1px solid #26393d;border-radius:14px;padding:12px}.education-editor-shell>aside button{text-align:left;display:flex;flex-direction:column}.education-editor-shell button.active{border-color:#62d3c0;background:#1b3537}.education-editor-shell>main{min-width:0;background:#f7faf9;color:#172326;border-radius:14px;overflow:hidden}.education-editor-shell>main>header{display:flex;justify-content:space-between;gap:12px;padding:12px;background:#e8f0ee;border-bottom:1px solid #ccd9d6}.education-editor-shell>main>header input{flex:1;border:0;background:transparent;font-size:19px;font-weight:700;outline:0}.education-editor-shell>main>header>div{display:flex;gap:7px}.education-editor-shell button.danger{color:#ffc4bd;border-color:#734841;background:#33211f} +.education-action-layout{display:grid;grid-template-columns:250px 1fr;gap:14px}.education-action-layout aside{display:flex;flex-direction:column;gap:8px}.education-action-layout main{min-height:420px}.education-metrics{display:flex;gap:12px;margin:18px 0}.education-metrics b{font-size:26px;background:#0d191d;border-radius:12px;padding:14px 22px}.education-metrics small{font-size:11px;display:block;color:#91a6a7}.education-preview{margin-top:15px;padding:16px;border:1px solid #40726d;background:#102725;border-radius:12px}.education-preview p{color:#c9d7d5}.education-preview code{display:block;color:#78dbc9;margin:10px 0}.education-rule{margin-top:18px} +.education-import>header{display:flex;justify-content:space-between;align-items:center}.education-import dl{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.education-import dl div{background:#0d181c;border-radius:10px;padding:12px}.education-import dt{color:#789092;font-size:12px}.education-import dd{margin:5px 0 0;overflow-wrap:anywhere}.education-import section article{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-top:1px solid #273b3e}.education-import section article div{display:flex;flex-direction:column}.education-import small{color:#89a0a1}.education-message{position:sticky;bottom:14px;background:#17302f;border:1px solid #4e8b83;color:#d8fff8;border-radius:12px;padding:12px 16px;margin:16px 0 0} +@media(max-width:900px){.education-overview{grid-template-columns:repeat(2,1fr)}.education-editor-shell,.education-action-layout{grid-template-columns:1fr}.education-editor-shell>aside{max-height:180px;overflow:auto}.education-hero{grid-template-columns:1fr}.education-hero>button{justify-self:start}.education-import dl{grid-template-columns:1fr}} diff --git a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts index dc1109925..047c3ce54 100644 --- a/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts +++ b/product-source/hololake-native-desktop/src/modules/numbered-ipc.ts @@ -672,6 +672,150 @@ const ROUTES = { "moduleNumber": "HLP-NIPC-MOD-0023", "operationNumber": "HLP-NIPC-OP-0084", "targetNumber": "HLP-NIPC-TGT-0023" + }, + "get_education_workspace_snapshot": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0085", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "create_education_document": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0086", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "read_education_document": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0087", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "save_education_document": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0088", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "archive_education_document": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0089", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "create_education_table": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0090", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "read_education_table": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0091", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "save_education_table": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0092", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "archive_education_table": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0093", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "assign_imported_table_to_education": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0094", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "import_education_tables_from_dialog": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0095", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "export_education_table_to_dialog": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0096", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "get_education_recognition_capability": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0097", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "create_education_automation_rule": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0098", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "save_education_automation_rule": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0099", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "archive_education_automation_rule": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0100", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "preview_education_automation_rule": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0101", + "targetNumber": "HLP-NIPC-TGT-0024" + }, + "execute_education_automation_rule": { + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0024", + "operationNumber": "HLP-NIPC-OP-0102", + "targetNumber": "HLP-NIPC-TGT-0024" } } as const