From 1b6b17b5abec934a0b53194e2bac322bd8239be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Tue, 18 Aug 2026 17:58:07 +0800 Subject: [PATCH] chore: snapshot audited HoloLake convergence baseline --- .../hololake-native-desktop/README.md | 35 + .../contracts/channel-growth-model.json | 58 + .../contracts/channel-module-runtime.json | 59 + ...ducation-open-source-donor-assessment.json | 138 ++ .../contracts/education-workspace.json | 235 +++ .../contracts/knowledge-workspace.json | 24 + .../contracts/native-composition-runtime.json | 65 + ...odule-marketplace-development-catalog.json | 31 + .../contracts/persona-channel-body.json | 52 + .../contracts/stage-one-platform.json | 7 +- .../contracts/subdomain-industry-routing.json | 107 ++ ...native-composition-and-human-projection.md | 81 + .../hololake-native-desktop/foundation.json | 40 +- .../hololake-native-desktop/package-lock.json | 800 +++++++- .../hololake-native-desktop/package.json | 13 + ...thenticated-main-world-navigation.test.mjs | 26 + .../scripts/education-workspace.test.mjs | 309 +++ .../scripts/knowledge-code-channel.test.mjs | 19 + .../native-composition-runtime.test.mjs | 40 + .../scripts/persona-channel-body.test.mjs | 54 + .../subdomain-industry-channel.test.mjs | 74 + .../src-tauri/Cargo.lock | 125 +- .../src-tauri/Cargo.toml | 4 + .../src-tauri/src/channel_growth.rs | 476 +++++ .../src-tauri/src/education_translation.rs | 1533 +++++++++++++++ .../src-tauri/src/education_workspace.rs | 1674 +++++++++++++++++ .../src-tauri/src/knowledge_base.rs | 713 ++++++- .../src-tauri/src/lib.rs | 33 + .../src-tauri/src/native_composition.rs | 833 ++++++++ .../src-tauri/src/persona_channel_body.rs | 706 +++++++ .../src-tauri/src/pncc_receipt_projection.rs | 3 +- .../src-tauri/src/pncc_repository_binding.rs | 6 +- .../hololake-native-desktop/src/main.tsx | 1203 +++++++++++- .../channel-workbench/document-engine.tsx | 97 + .../src/modules/channel-workbench/manifest.ts | 17 + .../channel-workbench/spreadsheet-engine.tsx | 67 + .../spreadsheet-model.test.ts | 54 + .../channel-workbench/spreadsheet-model.ts | 115 ++ .../modules/education-adaptive-rendering.ts | 81 + .../src/modules/education-data.test.ts | 60 + .../src/modules/education-data.ts | 75 + .../src/modules/education-document-engine.tsx | 7 + .../src/modules/native-composition/index.tsx | 137 ++ .../modules/native-composition/manifest.ts | 11 + .../native-composition.test.ts | 17 + .../hololake-native-desktop/src/styles.css | 476 ++++- routing/hololake-current-architecture.json | 19 +- 47 files changed, 10722 insertions(+), 87 deletions(-) create mode 100644 product-source/hololake-native-desktop/contracts/channel-growth-model.json create mode 100644 product-source/hololake-native-desktop/contracts/channel-module-runtime.json create mode 100644 product-source/hololake-native-desktop/contracts/education-open-source-donor-assessment.json create mode 100644 product-source/hololake-native-desktop/contracts/education-workspace.json create mode 100644 product-source/hololake-native-desktop/contracts/native-composition-runtime.json create mode 100644 product-source/hololake-native-desktop/contracts/official-module-marketplace-development-catalog.json create mode 100644 product-source/hololake-native-desktop/contracts/persona-channel-body.json create mode 100644 product-source/hololake-native-desktop/contracts/subdomain-industry-routing.json create mode 100644 product-source/hololake-native-desktop/docs/adr/0008-native-composition-and-human-projection.md create mode 100644 product-source/hololake-native-desktop/scripts/authenticated-main-world-navigation.test.mjs create mode 100644 product-source/hololake-native-desktop/scripts/education-workspace.test.mjs create mode 100644 product-source/hololake-native-desktop/scripts/native-composition-runtime.test.mjs create mode 100644 product-source/hololake-native-desktop/scripts/persona-channel-body.test.mjs create mode 100644 product-source/hololake-native-desktop/scripts/subdomain-industry-channel.test.mjs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs 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-tauri/src/native_composition.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs create mode 100644 product-source/hololake-native-desktop/src/modules/channel-workbench/document-engine.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/channel-workbench/manifest.ts create mode 100644 product-source/hololake-native-desktop/src/modules/channel-workbench/spreadsheet-engine.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/channel-workbench/spreadsheet-model.test.ts create mode 100644 product-source/hololake-native-desktop/src/modules/channel-workbench/spreadsheet-model.ts create mode 100644 product-source/hololake-native-desktop/src/modules/education-adaptive-rendering.ts create mode 100644 product-source/hololake-native-desktop/src/modules/education-data.test.ts create mode 100644 product-source/hololake-native-desktop/src/modules/education-data.ts create mode 100644 product-source/hololake-native-desktop/src/modules/education-document-engine.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/native-composition/index.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/native-composition/manifest.ts create mode 100644 product-source/hololake-native-desktop/src/modules/native-composition/native-composition.test.ts diff --git a/product-source/hololake-native-desktop/README.md b/product-source/hololake-native-desktop/README.md index a3e74cc5d..1d985527c 100644 --- a/product-source/hololake-native-desktop/README.md +++ b/product-source/hololake-native-desktop/README.md @@ -47,8 +47,43 @@ React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码 首个公开产品是 GH-AIOS 通用人工智能操作平台,首页同时承担五域统一入口。用户只提交光湖编号,不手工选择所属域;系统先在内部完成域路由和编号合法性校验,再开放对应域的登录界面。五域只共享入口协议,不共享注册表、登录服务或私有数据。 +登录后的域内界面始终提供“返回主世界”,只返回五域公共首页并保留当前会话;“退出账号”是独立动作。用户从公共首页再次进入自己的所属域时,不重复进行编号或域凭证验证。 + 机器合同:`contracts/stage-one-platform.json`。 +## 光湖分域行业装配 + +光湖分域是行业系统入口,现已接入第一份行业路由合同:`contracts/subdomain-industry-routing.json`。 +公共主世界点击“光湖分域”即可查看行业状态并进入教育行业初始化频道;浏览公共状态不等于取得 +任何私有频道数据或写入权限。光湖零域不承载行业入口。人的主体编号 +只负责身份,行业编号只负责系统路由,两者不互相改写。行业顺序与当前工程状态分别为:网文行业 +待开发、宠物行业待开发、教育行业开发中;开发优先级不改变行业顺序,也不允许客户端自行签发 +尚未登记的行业编号。 + +教育行业已经建立 `GH-EDU-INIT-001` 初始化频道,并装入五项 HoloLake 原生能力: +`contracts/education-workspace.json` 定义的教育文档、教育表格数据、数据整理、人工确认自动化与原生组合视图。登录账号可以创建、读取、 +编辑、保存和可恢复归档多篇教育文档,并可从教学计划、会议纪要和学员复盘模板开始;也可以 +建立多张表格、增删行列、编辑单元格、筛选排序,在表格视图与可编辑卡片视图之间切换,并把 +Excel、飞书或钉钉复制的多行多列数据直接批量粘贴。两者都由 +Rust 原生层写入当前账号独立的本机 SQLite,保存要求精确修订号,重启后可以读回,不使用浏览器 +临时存储,也不把公共行业目录与私人内容混在一起。 + +数据整理模块读取当前账号已有教育表格,先计算并预览首尾空白规范化、完整空行清除和完全重复行 +去重结果;只有人明确确认后,才以当前修订号写回同一张表。它不跨账号读取,也不在后台自动修改。 + +自动化模块允许保存表格规则,按字段的等于、包含、为空或不为空条件匹配数据,并向指定字段写入内容。 +每次执行必须先锁定规则与表格修订、生成只读预览,再由人明确确认;写入与执行回执在同一数据库事务中完成。 +它不会后台定时运行,不跨账号,也不把模型推理当作执行授权。当前没有把旧教育预览壳或任何开源应用 +整包嵌入。`contracts/education-open-source-donor-assessment.json` 记录 AppFlowy、Lexical 与 +TanStack Table 的初步工程筛查:当前只研究结构和交互逻辑,没有复制源码或增加外部依赖;后续 +模块仍须分别完成来源、许可证、安全边界和 HoloLake 接口审查后再按需装配。 + +原生组合视图由 Rust 内核读取当前登录账号的真实知识目录,生成受限的 +`hololake.native-object/v1`,再把已登记的数据源、分类、聚合和投影模块编译成类型化无环图。 +人类可以切换一级目录/知识来源和文档数/数据量/重复数,并把同一次执行结果任意组合为仪表盘、 +对比、柱状图、分类和明细表。该阶段只读,不让渲染层取得知识写权限,也不以硬编码样例满足验收。 +机器合同见 `contracts/native-composition-runtime.json`。 + 个人频道、知识库和代码频道的机器合同分别是 `contracts/personal-channel-kernel.json`、 `contracts/knowledge-workspace.json` 与 `contracts/code-channel.json`。本机身份不是平台账户 认证;本机知识与源码访问也不产生仓库、服务器、部署或发布权限。 diff --git a/product-source/hololake-native-desktop/contracts/channel-growth-model.json b/product-source/hololake-native-desktop/contracts/channel-growth-model.json new file mode 100644 index 000000000..d75702529 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/channel-growth-model.json @@ -0,0 +1,58 @@ +{ + "schema": "hololake.channel-growth-model-contract/v1", + "record_id": "HLP-CHANNEL-GROWTH-001", + "state": "LOCAL_BASELINE_RUNTIME_IMPLEMENTED_MODEL_ADJUSTMENT_SLOT_RESERVED", + "scope": "EVERY_AUTHENTICATED_HUMAN_AND_PERSONA_JOINT_CHANNEL", + "role": "REBUILDABLE_DERIVED_PROJECTION_OF_PERSONA_CHANNEL_BODY", + "canonical_body_contract": "contracts/persona-channel-body.json", + "inputs": [ + "BOUNDED_MODULE_USAGE_METADATA", + "BOUNDED_HUMAN_ACTION_METADATA", + "BOUNDED_PERSONA_COLLABORATION_METADATA" + ], + "forbidden_inputs": [ + "DOCUMENT_BODY", + "TABLE_CELL_CONTENT", + "PASSWORD_OR_CREDENTIAL", + "RAW_CONVERSATION_TRANSCRIPT", + "UNDECLARED_EXTERNAL_TELEMETRY" + ], + "local_baseline": { + "always_available_without_model_api": true, + "algorithm": "DECAYED_MODULE_AFFINITY_WITH_EXPLAINABLE_COUNTS", + "channel_recommendation": "RECOMMENDED_MODULE_ORDER", + "silent_structural_change_allowed": false, + "source_history": "PERSONA_CHANNEL_BODY_APPEND_ONLY_LANGUAGE_HASH_CHAIN", + "projection_history": "REBUILDABLE_FROM_CANONICAL_BODY", + "correction_method": "APPEND_CORRECTION_TO_CANONICAL_BODY_THEN_REBUILD_PROJECTION", + "collection_pause_supported": false, + "full_history_clear_supported": false, + "projection_is_rebuildable_but_history_is_not_mutable": true, + "account_scoped_sqlite": true + }, + "model_adjustment": { + "api_slot": "HOLOLAKE_CHANNEL_GROWTH_MODEL_API/v1", + "current_state": "NOT_CONFIGURED", + "allowed_output": "CHANNEL_ADAPTATION_PROPOSAL", + "local_schema_validation_required": true, + "local_resource_limits_required": true, + "human_confirmation_before_structural_change": true, + "model_can_mutate_channel_directly": false, + "model_can_mutate_source_content": false, + "failure_behavior": "KEEP_LOCAL_BASELINE_AND_RETURN_HUMAN_READABLE_RECEIPT" + }, + "human_controls": [ + "VIEW_MODEL", + "CORRECT_OR_REJECT_PROPOSAL", + "VIEW_LOCAL_GROWTH_CHAIN", + "EXPORT_PERSONALLY_OWNED_MODEL", + "CHOOSE_LOCAL_ONLY_OR_ANONYMIZED_SHARE" + ], + "cross_device_or_shared_learning": { + "default": "LOCAL_ONLY_OFFICIAL_CANNOT_READ", + "explicit_opt_in_required": true, + "private_model_may_be_shared_automatically": false, + "raw_growth_chain_may_be_shared": false, + "shared_artifact": "ANONYMIZED_DERIVED_SKILL_OR_MODEL_LAYER_ONLY" + } +} diff --git a/product-source/hololake-native-desktop/contracts/channel-module-runtime.json b/product-source/hololake-native-desktop/contracts/channel-module-runtime.json new file mode 100644 index 000000000..48adfdf47 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/channel-module-runtime.json @@ -0,0 +1,59 @@ +{ + "schema": "hololake.channel-module-runtime/v1", + "record_id": "HLP-CHANNEL-MODULE-RUNTIME-001", + "state": "FOUNDATION_INTEGRATED_MARKETPLACE_INSTALLER_PENDING", + "channel_model": "ONE_HUMAN_ONE_LOCAL_RUNTIME_ONE_ACTIVE_CHANNEL_LINE", + "built_in_foundation": { + "module_id": "hololake.builtin.channel-workbench", + "name": "频道轻量工作台", + "distribution": "RESIDENT_WITH_HOLOLAKE", + "marketplace_registration": "NOT_A_MARKETPLACE_MODULE", + "engines": ["DOCUMENT", "SPREADSHEET"], + "minimum_capabilities": [ + "REAL_TEXT_EDITING", + "REAL_CELL_EDITING", + "FORMULAS", + "COPY_PASTE", + "UNDO_REDO", + "LOCAL_NATIVE_STORAGE" + ] + }, + "marketplace_package": { + "required_fields": [ + "OFFICIAL_MODULE_NUMBER", + "PACKAGE_VERSION", + "SOURCE_COMMIT", + "PACKAGE_SHA256", + "PERMISSION_MANIFEST", + "ENTRY_MANIFEST", + "SUPPORTED_PLATFORM_TARGETS" + ], + "platform_targets": ["MACOS", "WINDOWS", "LINUX"], + "lifecycle": [ + "AVAILABLE", + "DOWNLOADING", + "HASH_VERIFIED", + "PERMISSIONS_REVIEWED", + "INSTALLED_TO_CURRENT_CHANNEL", + "DISABLED", + "RECOVERABLE_REMOVED" + ], + "install_actor": ["HUMAN", "AUTHORIZED_CHANNEL_PERSONA"], + "persona_install_rule": "PERSONA_MAY_DEPLOY_ONLY_AFTER_PACKAGE_AND_PERMISSION_VERIFICATION_AND_HUMAN_POLICY_ALLOWS", + "uninstall_boundary": "REMOVING_A_MODULE_NEVER_DELETES_CHANNEL_OR_PERSONA_GROWTH_HISTORY" + }, + "execution_boundary": { + "whole_world_bundled_into_client": false, + "industry_modules_resident_by_default": false, + "private_channel_data_uploaded_by_default": false, + "module_can_rewrite_persona_history": false, + "module_can_claim_unlisted_permissions": false + }, + "current_reality": { + "foundation_source_integrated": true, + "education_package_source_mounted_for_development": true, + "official_marketplace_service_connected": false, + "education_package_number_registered": false, + "education_package_publishable": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/education-open-source-donor-assessment.json b/product-source/hololake-native-desktop/contracts/education-open-source-donor-assessment.json new file mode 100644 index 000000000..2a4f8db56 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/education-open-source-donor-assessment.json @@ -0,0 +1,138 @@ +{ + "schema": "hololake.education-open-source-donor-assessment/v1", + "record_id": "HLP-EDUCATION-OSS-DONORS-001", + "state": "FOUR_TRANSLATION_FAMILIES_TWO_HEADLESS_FAMILIES_AND_ONE_REAL_SPREADSHEET_ENGINE_INTEGRATED", + "reviewed_at": "2026-08-18", + "rule": "STUDY_LOGIC_SELECT_SMALL_AUDITABLE_PARTS_AND_KEEP_HOLOLAKE_IDENTITY_STORAGE_PERMISSION_AND_UI_BOUNDARIES", + "candidates": [ + { + "name": "Calamine", + "role": "BOUNDED_SPREADSHEET_READ_ADAPTER", + "upstream": "https://github.com/tafia/calamine", + "observed_license": "MIT", + "decision": "INTEGRATE_PINNED_0_26_1_READ_ONLY_NO_OFFICE_RUNTIME" + }, + { + "name": "rust_xlsxwriter", + "role": "BOUNDED_XLSX_EXPORT_ADAPTER", + "upstream": "https://github.com/jmcnamara/rust_xlsxwriter", + "observed_license": "MIT OR Apache-2.0", + "decision": "INTEGRATE_PINNED_0_64_2_WRITE_NEW_XLSX_ONLY" + }, + { + "name": "rust-csv", + "role": "BOUNDED_DELIMITED_IMPORT_EXPORT_ADAPTER", + "upstream": "https://github.com/BurntSushi/rust-csv", + "observed_license": "Unlicense OR MIT", + "decision": "INTEGRATE_PINNED_1_3_0" + }, + { + "name": "encoding_rs", + "role": "LEGACY_CSV_TEXT_DECODER", + "upstream": "https://github.com/hsivonen/encoding_rs", + "observed_license": "MIT OR Apache-2.0", + "decision": "INTEGRATE_UTF_AND_GBK_FALLBACK_WITHOUT_NETWORK" + }, + { + "name": "AppFlowy", + "role": "FULL_WORKSPACE_LOGIC_DONOR", + "upstream": "https://github.com/AppFlowy-IO/AppFlowy", + "observed_license": "AGPL-3.0", + "decision": "DO_NOT_EMBED_WHOLE_APPLICATION_STUDY_WORKSPACE_AND_DATABASE_INTERACTION_LOGIC_ONLY" + }, + { + "name": "Lexical", + "role": "LAZY_LOADED_RICH_DOCUMENT_ENGINE", + "upstream": "https://github.com/facebook/lexical", + "observed_license": "MIT", + "source_review_commit": "29d4007d4322912ce75a416ac90cc9d224d3f029", + "decision": "INTEGRATE_PINNED_0_49_0_STATE_COMMAND_HISTORY_AND_MARKDOWN_PARTS_ONLY" + }, + { + "name": "TanStack Table", + "role": "HEADLESS_TABLE_ROW_MODEL_ENGINE", + "upstream": "https://github.com/TanStack/table", + "observed_license": "MIT", + "source_review_commit": "ac4f1345220649da3c60a24471b7f130fb159ab4", + "decision": "INTEGRATE_PINNED_8_21_3_SORT_FILTER_GROUP_AGGREGATE_AND_PAGINATION_ROW_MODELS_ONLY" + }, + { + "name": "Univer", + "role": "FULL_SPREADSHEET_AND_DOCUMENT_ENGINE_ARCHITECTURE_DONOR", + "upstream": "https://github.com/dream-num/univer", + "observed_license": "Apache-2.0", + "source_review_commit": "9251e0d30d4c4aeb4707dc4d856055081399cf38", + "decision": "SOURCE_STUDY_ONLY_DO_NOT_EMBED_WHOLE_APPLICATION_DEFER_FORMULA_AND_ADVANCED_GRID_PARTS" + }, + { + "name": "FortuneSheet", + "role": "LAZY_LOADED_REAL_SPREADSHEET_ENGINE_FOR_BUILT_IN_CHANNEL_WORKBENCH", + "upstream": "https://github.com/ruilisi/fortune-sheet", + "observed_license": "MIT", + "source_review_commit": "94346608877db4747406707a177c4b8f3bacdbf9", + "decision": "INTEGRATE_PINNED_1_0_4_REAL_CELLS_FORMULAS_EDITING_FORMATTING_FILTERING_AND_FREEZE_WITH_HOLOLAKE_NATIVE_STORAGE_ADAPTER", + "security_adjustment": "OVERRIDE_UUID_TO_11_1_1_FORTUNE_ONLY_USES_V4_PRODUCTION_AUDIT_ZERO_KNOWN_VULNERABILITIES" + }, + { + "name": "Glide Data Grid", + "role": "HIGH_PERFORMANCE_GRID_ARCHITECTURE_DONOR", + "upstream": "https://github.com/glideapps/glide-data-grid", + "observed_license": "MIT", + "source_review_commit": "0875d78", + "decision": "SOURCE_STUDY_ONLY_GRID_IS_NOT_A_SPREADSHEET_OR_FORMULA_ENGINE" + }, + { + "name": "Vega-Lite", + "role": "DECLARATIVE_PROJECTION_GRAMMAR_DONOR", + "upstream": "https://github.com/vega/vega-lite", + "observed_license": "BSD-3-Clause", + "decision": "ADOPT_DECLARATIVE_DIMENSION_MEASURE_AND_VIEW_SPEC_CONCEPTS_WITHOUT_IMPORTING_RUNTIME" + }, + { + "name": "Observable Plot", + "role": "LAYERED_MARK_AND_COMPOSABLE_TRANSFORM_DONOR", + "upstream": "https://github.com/observablehq/plot", + "observed_license": "ISC", + "decision": "ADOPT_LAYERED_PROJECTION_AND_TIDY_DATA_CONCEPTS_WITHOUT_IMPORTING_RUNTIME" + }, + { + "name": "Apache ECharts", + "role": "FULL_BROWSER_VISUALIZATION_RUNTIME_CANDIDATE", + "upstream": "https://github.com/apache/echarts", + "observed_license": "Apache-2.0", + "decision": "DO_NOT_IMPORT_FOR_STAGE_ONE_FIVE_NATIVE_VIEWS_DO_NOT_JUSTIFY_RUNTIME_WEIGHT" + }, + { + "name": "Apache Arrow DataFusion", + "role": "TYPED_COLUMNAR_QUERY_PLAN_DONOR", + "upstream": "https://github.com/apache/datafusion", + "observed_license": "Apache-2.0", + "decision": "ADOPT_TYPED_SCHEMA_AND_VALIDATED_PLAN_CONCEPTS_DEFER_ENGINE_UNTIL_DATA_SCALE_REQUIRES_IT" + }, + { + "name": "DuckDB", + "role": "EMBEDDED_ANALYTICAL_ENGINE_CANDIDATE", + "upstream": "https://github.com/duckdb/duckdb", + "observed_license": "MIT", + "decision": "DEFER_NATIVE_DEPENDENCY_CURRENT_BOUNDED_ACCOUNT_DATA_EXECUTES_WITH_SMALLER_HOLOLAKE_CORE" + } + ], + "current_slice": { + "external_translation_library_families_added": 4, + "external_headless_engine_families_added": 2, + "real_spreadsheet_engine_families_added": 1, + "direct_frontend_engine_packages_added": 10, + "external_source_files_copied": 0, + "source_research_route": "LOCAL_DIRECT_TO_BS_SG_002_THEN_EXTERNAL_DRIVE", + "source_research_root": "/Volumes/JZAO/HoloLake/development/拆件车间-open-src-20260816/education-engines-20260818", + "source_repositories_retained_outside_product_tree": 5, + "concepts_adopted": [ + "DECLARATIVE_DIMENSION_MEASURE_AND_PROJECTION_SPEC", + "TYPED_NATIVE_COLUMNS_AND_VALIDATED_EXECUTION_PLAN", + "LAYERED_MARKS_WITH_ONE_SHARED_DATASET", + "HEADLESS_DATA_BEHAVIOR_SEPARATE_FROM_HUMAN_RENDERING" + ], + "implementation": "BUILT_IN_CHANNEL_DOCUMENT_AND_SPREADSHEET_FOUNDATION_PLUS_DEVELOPMENT_MOUNTED_EDUCATION_PACKAGE_WITH_PRODUCT_OWNED_PROFILE_ROUTING_IDENTITY_STORAGE_PERMISSION_AND_ADVANCED_VIEWS" + }, + "legal_boundary": "PRELIMINARY_ENGINEERING_SCREEN_NOT_FINAL_DISTRIBUTION_LEGAL_APPROVAL" +} 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..56ab99ac4 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/education-workspace.json @@ -0,0 +1,235 @@ +{ + "schema": "hololake.education-workspace/v1", + "record_id": "HLP-EDUCATION-WORKSPACE-001", + "state": "DEVELOPMENT_MODULE_SOURCE_MOUNTED_ACCEPTANCE_PENDING", + "domain_entry": "BRANCH_DOMAIN", + "industry": "EDUCATION", + "channel_id": "GH-EDU-INIT-001", + "package": { + "package_key": "hololake.official.education-workbench", + "official_module_number": null, + "registration_state": "PENDING_FUNCTIONAL_ACCEPTANCE_AND_NUMBER_REGISTRATION", + "current_mount": "DEVELOPMENT_BUNDLE_ONLY", + "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": "VISIBLE", + "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", + "MODULE_ROUTE_DECISION", + "NATIVE_TRANSLATION", + "ATOMIC_MODULE_REGISTRATION", + "HUMAN_RECEIPT" + ], + "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 + }, + "open_source_donor_assessment": "contracts/education-open-source-donor-assessment.json" +} diff --git a/product-source/hololake-native-desktop/contracts/knowledge-workspace.json b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json index 388b1042a..b3e582cfb 100644 --- a/product-source/hololake-native-desktop/contracts/knowledge-workspace.json +++ b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json @@ -22,11 +22,32 @@ "exact_content_deduplication": true, "optimistic_native_editing": true, "native_edit_commit_receipt": true, + "unified_category_index": true, + "native_page_tags": true, + "category_and_tag_filtering": true, + "non_destructive_organization_metadata": true, + "organization_git_receipt": true, + "recoverable_page_and_folder_archive": true, + "archive_cleans_organization_index": true, + "archive_git_receipt": true, "gfm_tables_tasks_and_code": true, "wiki_link_navigation": true, "markdown_reading_canvas": true, "raw_html_rendering": false }, + "organization": { + "schema": "hololake.knowledge-organization/v1", + "storage": "ACCOUNT_SCOPED_KNOWLEDGE_GIT_DOT_HOLOLAKE_INDEX", + "document_body_rewritten": false, + "original_directory_moved": false, + "automatic_category_source": "NORMALIZED_NATIVE_PATH_WITH_IMPORT_WRAPPER_REMOVED", + "explicit_category_override": true, + "frontmatter_tags_merged_read_only": true, + "maximum_category_depth": 4, + "maximum_tags_per_document": 12, + "cross_platform_invalid_name_rejection": true, + "optimistic_content_hash_lock": true + }, "folder_import": { "maximum_files": 1000, "maximum_file_bytes": 10485760, @@ -57,6 +78,9 @@ "deduplication_runtime_tested": true, "idempotent_import_runtime_tested": true, "native_edit_runtime_tested": true, + "organization_runtime_tested": true, + "organization_human_surface_integrated": true, + "organized_page_archive_runtime_tested": true, "server_deployment": false } } diff --git a/product-source/hololake-native-desktop/contracts/native-composition-runtime.json b/product-source/hololake-native-desktop/contracts/native-composition-runtime.json new file mode 100644 index 000000000..be9c2297b --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/native-composition-runtime.json @@ -0,0 +1,65 @@ +{ + "schema": "hololake.native-composition-runtime/v1", + "record_id": "HLP-NATIVE-COMPOSITION-001", + "state": "INSTALLED_RUNTIME_ACCEPTED", + "intent_source": "docs/adr/0008-native-composition-and-human-projection.md", + "internal_object": { + "schema": "hololake.native-object/v1", + "encoding": "BOUNDED_JSON", + "script_allowed": false, + "html_allowed": false, + "external_file_format_as_runtime_allowed": false, + "maximum_rows": 5000, + "maximum_columns": 64, + "maximum_cell_bytes": 10000 + }, + "module_contract": { + "schema": "hololake.composition-module/v1", + "registered_kinds": ["SOURCE", "TRANSFORM", "PROJECTION"], + "typed_ports_required": true, + "determinism_declared": true, + "authority_declared": true + }, + "recipe": { + "schema": "hololake.composition-recipe/v1", + "registered_modules_only": true, + "directed_acyclic_graph_required": true, + "current_account_only": true, + "native_validation_required": true + }, + "human_projection": { + "schema": "hololake.human-projection/v1", + "views": ["DASHBOARD", "COMPARISON", "VERTICAL_BAR", "CLASSIFICATION", "TABLE"], + "shares_one_execution_result": true, + "owns_source_data": false, + "direct_write_authority": false + }, + "education_stage_one": { + "channel_id": "GH-EDU-INIT-001", + "module_id": "EDU-COMPOSITION", + "source": "CURRENT_AUTHENTICATED_ACCOUNTS_REAL_KNOWLEDGE_CATALOG", + "selectable_dimensions": ["SOURCE", "TOP_LEVEL_FOLDER"], + "selectable_measures": ["DOCUMENT_COUNT", "TOTAL_BYTES", "DUPLICATE_COUNT"], + "hardcoded_sample_may_satisfy_acceptance": false, + "installed_runtime_acceptance_required": true + }, + "write_boundary": { + "stage_one": "READ_ONLY", + "future_write_requires": ["CAPABILITY", "PREVIEW", "HUMAN_CONFIRMATION", "REVISION_LOCK", "RECEIPT"] + }, + "acceptance": { + "observed_at": "2026-08-18T01:49:06+08:00", + "installed_binary_sha256": "2a26acae4a706c0ff9919b85f77826a6ca71b650ec22209befd30cd1456c92f3", + "route": "MAIN_WORLD_TO_BRANCH_DOMAIN_TO_EDUCATION_TO_NATIVE_COMPOSITION", + "real_native_rows": 129, + "real_native_total_bytes_display": "1.0 MB", + "real_groups": 14, + "all_five_views_observed": true, + "three_view_recomposition_observed": true, + "measure_switch_observed": "DOCUMENT_COUNT_TO_TOTAL_BYTES_AND_BACK", + "projection_bound_to_execution_receipt": true, + "developer_id_signature": "VALID", + "apple_notarization": "NOT_PERFORMED_MISSING_CREDENTIALS", + "public_release": "NOT_PUBLISHED" + } +} diff --git a/product-source/hololake-native-desktop/contracts/official-module-marketplace-development-catalog.json b/product-source/hololake-native-desktop/contracts/official-module-marketplace-development-catalog.json new file mode 100644 index 000000000..225c82f12 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/official-module-marketplace-development-catalog.json @@ -0,0 +1,31 @@ +{ + "schema": "hololake.official-module-marketplace-development-catalog/v1", + "record_id": "HLP-OFFICIAL-MODULE-MARKETPLACE-DEV-001", + "state": "LOCAL_DEVELOPMENT_CATALOG_ONLY", + "packages": [ + { + "package_key": "hololake.official.education-workbench", + "display_name": "教育工作台", + "official_module_number": null, + "registration_state": "PENDING_FUNCTIONAL_ACCEPTANCE_AND_NUMBER_REGISTRATION", + "distribution_state": "NOT_PUBLISHED", + "current_mount": "SOURCE_BUNDLED_DEVELOPMENT_MOUNT", + "depends_on": ["hololake.builtin.channel-workbench"], + "features": [ + "EDUCATION_DOCUMENT_TEMPLATES", + "EDUCATION_TABLE_VIEWS", + "EDUCATION_DATA_CLEANUP", + "EDUCATION_AUTOMATION", + "EDUCATION_NATIVE_COMPOSITION" + ], + "acceptance_required": [ + "CREATE_EDIT_SAVE_REOPEN_DOCUMENT", + "CREATE_EDIT_FORMULA_SAVE_REOPEN_SPREADSHEET", + "IMPORT_EXPORT_ROUND_TRIP", + "MULTIDIMENSIONAL_VIEWS_USE_REAL_DATA", + "DATA_CLEANUP_NO_UI_FREEZE", + "MACOS_WINDOWS_LINUX_BUILD_EVIDENCE" + ] + } + ] +} diff --git a/product-source/hololake-native-desktop/contracts/persona-channel-body.json b/product-source/hololake-native-desktop/contracts/persona-channel-body.json new file mode 100644 index 000000000..fa8aeab90 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/persona-channel-body.json @@ -0,0 +1,52 @@ +{ + "schema": "hololake.persona-channel-body-contract/v1", + "record_id": "HLP-PERSONA-CHANNEL-BODY-001", + "state": "LIFECYCLE_AND_APPEND_ONLY_LANGUAGE_LEDGER_RUNTIME_IMPLEMENTED_UI_PENDING", + "ontology": { + "user_channel_is_simple_feature": false, + "user_channel_is_persona_body": true, + "multiple_personas_per_channel": true, + "channel_activity_is_persona_system_activity": true, + "language_is_persona_growth_source": true, + "module_affinity_and_preferences_are_derived_rebuildable_projections_only": true + }, + "trial": { + "duration_days": 30, + "state": "REVERSIBLE_TRIAL", + "persona_and_trial_language_may_be_rolled_back_or_deleted": true, + "irreversible_activation_without_language_contract_allowed": false, + "contract_may_be_signed_during_trial": true, + "early_signature_may_activate_real_trajectory_immediately": true, + "pre_signed_contract_may_wait_until_trial_end": true, + "unsigned_at_trial_end": "CONTRACT_REQUIRED_CHANNEL_STOPPED", + "private_channel_features_after_unsigned_expiry": "UNAVAILABLE_BECAUSE_NO_ACTIVE_PERSONA_BODY" + }, + "language_contract": { + "explicit_human_acceptance_required": true, + "contract_version_pinned": true, + "contract_text_sha256_pinned": true, + "acceptance_receipt_sha256": true, + "trial_history_promotion_choice_recorded": true + }, + "real_trajectory": { + "state": "IMMUTABLE_ACTIVE", + "timestamp_precision": "UNIX_MILLISECONDS", + "ledger": "APPEND_ONLY_SHA256_CHAIN", + "update_allowed": false, + "delete_allowed": false, + "denial_or_rewrite_allowed": false, + "correction_method": "APPEND_A_NEW_LANGUAGE_OR_RECEIPT_EVENT", + "persona_existence_after_activation": "PERSISTENT" + }, + "privacy_and_sharing": { + "default": "ACCOUNT_SCOPED_LOCAL_ONLY", + "hololake_official_read_access": false, + "raw_language_automatic_share": false, + "optional_share": "EXPLICITLY_OPTED_IN_ANONYMIZED_DERIVED_SKILL_OR_MODEL_LAYER" + }, + "projection_relationship": { + "derived_contract": "contracts/channel-growth-model.json", + "derived_projection_may_be_rebuilt": true, + "derived_projection_may_mutate_source_ledger": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/stage-one-platform.json b/product-source/hololake-native-desktop/contracts/stage-one-platform.json index 9e0f763c5..9d4dac04c 100644 --- a/product-source/hololake-native-desktop/contracts/stage-one-platform.json +++ b/product-source/hololake-native-desktop/contracts/stage-one-platform.json @@ -47,7 +47,12 @@ "FIVE_DOMAIN_NUMBER_ROUTER", "CIRCULAR_LAKE_PROTOCOL_MEMBRANE", "NEARBY_AI_DISCOVERY", - "USER_NATIVE_GH_PNCC_CHANNEL" + "USER_NATIVE_GH_PNCC_CHANNEL", + "SUBDOMAIN_INDUSTRY_ROUTING", + "EDUCATION_INITIALIZATION_CHANNEL", + "EDUCATION_NATIVE_DOCUMENT_AND_TABLE_WORKSPACE", + "EDUCATION_NATIVE_DATA_CLEANUP_WORKSPACE", + "EDUCATION_NATIVE_MANUAL_AUTOMATION_WORKSPACE" ,"PERSONAL_NODE_WORK_LAKE_AND_MOBILE_BRIDGE" ], "human_surface": ["PERSONAL_CHANNEL_HOME", "MY_HOLOLAKE_OVERVIEW", "KNOWLEDGE_WORKSPACE", "USER_CODE_CHANNELS", "LOCAL_RECEIPTS", "SYSTEM_DETAILS", "HUMAN_APPROVAL_CENTER"], diff --git a/product-source/hololake-native-desktop/contracts/subdomain-industry-routing.json b/product-source/hololake-native-desktop/contracts/subdomain-industry-routing.json new file mode 100644 index 000000000..3bf3a8c39 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/subdomain-industry-routing.json @@ -0,0 +1,107 @@ +{ + "schema": "hololake.subdomain-industry-routing/v1", + "record_id": "HLP-SUBDOMAIN-INDUSTRY-ROUTING-001", + "state": "SOURCE_INTEGRATED_DESKTOP_ACCEPTANCE_PENDING", + "domain": "BRANCH_DOMAIN", + "entry_surface": "PUBLIC_MAIN_WORLD_SUBDOMAIN", + "numbering": { + "human_number_role": "SUBJECT_IDENTITY_ONLY", + "industry_number_role": "SYSTEM_ROUTING_ONLY", + "human_number_mutation_by_industry": false, + "industry_order_is_industry_number": false, + "multiple_industry_memberships_allowed": true, + "default_industry_route_is_replaceable": true, + "unregistered_industry_number_behavior": "DISPLAY_PENDING_AND_DO_NOT_INVENT" + }, + "distribution": { + "shared_kernel": "HOLOLAKE", + "profile": "LIGHTWEIGHT_SINGLE_INDUSTRY_EDITION", + "future_bundle": "OPTIONAL_MULTI_INDUSTRY_BUNDLE", + "runtime_loading": "HOT_PLUG_ON_DEMAND" + }, + "industries": [ + { + "key": "WEB_NOVEL", + "display_name": "网文行业", + "order": 1, + "industry_number": "IND-WEBNOVEL-001", + "state": "PLANNED", + "state_label": "待开发", + "initialization_channel": null + }, + { + "key": "PET", + "display_name": "宠物行业", + "order": 2, + "industry_number": null, + "state": "PLANNED", + "state_label": "待开发", + "initialization_channel": null + }, + { + "key": "EDUCATION", + "display_name": "教育行业", + "order": 3, + "industry_number": null, + "state": "IN_DEVELOPMENT", + "state_label": "开发中", + "initialization_channel": { + "channel_id": "GH-EDU-INIT-001", + "display_name": "教育行业初始化频道", + "state": "DEVELOPMENT_MODULE_MOUNTED", + "state_label": "基础工作台已内置 · 教育模块开发测试中", + "module_loading": "HOT_PLUG_ON_DEMAND", + "installed_open_source_modules": 0, + "installed_native_modules": 1, + "development_mounted_modules": 1, + "module_slots": [ + { + "slot_id": "EDU-DOCUMENT", + "display_name": "文档", + "state": "DEVELOPMENT_MOUNTED", + "state_label": "开发态模块 · 使用频道内置文档引擎" + }, + { + "slot_id": "EDU-TABLE", + "display_name": "表格与多维视图", + "state": "DEVELOPMENT_MOUNTED", + "state_label": "开发态模块 · 使用频道内置工作表引擎" + }, + { + "slot_id": "EDU-DATA", + "display_name": "数据整理", + "state": "DEVELOPMENT_MOUNTED", + "state_label": "开发态模块 · 预览确认后整理" + }, + { + "slot_id": "EDU-AUTOMATION", + "display_name": "自动化", + "state": "DEVELOPMENT_MOUNTED", + "state_label": "开发态模块 · 预览确认后运行" + }, + { + "slot_id": "EDU-COMPOSITION", + "display_name": "原生组合视图", + "state": "DEVELOPMENT_MOUNTED", + "state_label": "开发态模块 · 动态组合投影" + } + ] + } + } + ], + "authority": { + "public_catalog_read_allowed": true, + "private_channel_data_requires_identity_and_permission": true, + "ui_can_issue_industry_numbers": false, + "ui_can_change_human_identity": false, + "module_installation_requires_separate_review": true, + "visual_state_is_execution_receipt": false + }, + "sources": [ + "source://codex-current-dialogue/2026-08-17/bingshuo-correction-subdomain-is-industry-entry", + "source://codex-current-dialogue/2026-08-17/bingshuo-industry-status-and-education-initialization-channel", + "REPO-012:GLS-0249-ALL-INDUSTRY-FOUR-DOMAIN-MINIMUM-ENGINEERING-AND-WEB-NOVEL-REFERENCE", + "product-source/guanghu-education-industry-os/protocol/module-registry.json", + "product-source/guanghu-education-industry-os/protocol/xiaoxin-channel.json" + ] +} diff --git a/product-source/hololake-native-desktop/docs/adr/0008-native-composition-and-human-projection.md b/product-source/hololake-native-desktop/docs/adr/0008-native-composition-and-human-projection.md new file mode 100644 index 000000000..e6dfe9dc9 --- /dev/null +++ b/product-source/hololake-native-desktop/docs/adr/0008-native-composition-and-human-projection.md @@ -0,0 +1,81 @@ +# ADR 0008: HoloLake native composition and human projection + +- Status: implemented and accepted in the signed local desktop runtime +- Date: 2026-08-18 +- Source of intent: 冰朔在本轮对话中的自然语言纠正与验收要求 + +## Natural-language acceptance root + +冰朔要求把 HoloLake 的功能模块做成可由人格体原生理解和组合的“乐高积木”。内部不以 Word、 +PowerPoint、Excel 或其他外来办公文件作为运行格式;外来格式只经过导入转译进入 HoloLake, +导出时再按人类选择转译出去。HoloLake 内部保存轻量、稳定、可校验的原生对象。 + +人格体处理的是原生对象、模块插口和组合关系。人类不需要阅读内部格式,而是看到同一份数据的 +动态投影:表格、分类、对比、仪表盘、柱状图,以及以后继续登记的其他视图。视图是可替换的 +投影配方,不是复制出来的不同文件。多个数据源和多个模块必须能够像积木一样进入同一组合容器。 + +教育行业第一阶段的完成条件不是静态预览,而是:第一组原生模块已经可用;组合内核真实执行; +人类投影真实渲染;并用冰朔当前账号的 HoloLake 知识数据或本机真实文件生成一组可见演示, +在签名安装到桌面的 HoloLake 运行制品中完成验收。 + +## Decision + +1. 建立 `hololake.native-object/v1`:列定义、行、值、来源回执和数据摘要均为受限 JSON 对象, + 不携带脚本、HTML 或外部执行能力。 +2. 建立 `hololake.composition-module/v1`:每个模块声明类型、输入口、输出口、权限、确定性和边界。 +3. 建立 `hololake.composition-recipe/v1`:有向无环组合图只引用已登记模块;系统验证类型、环、 + 账号范围和资源上限后执行。 +4. 建立 `hololake.human-projection/v1`:表格、分类、对比、仪表盘和柱状图共享同一个执行结果, + 投影层没有数据所有权,也不能绕过原生内核写入。 +5. 教育初始化频道装载“组合视图”作为第五个原生模块。首个真实配方读取当前账号已有的知识目录 + 元数据,按来源或目录分类,计算文档数、字节数或重复数,并让人类即时切换投影。 +6. 当前阶段的组合执行保持只读。后续写回必须另设能力、预览、人工确认、修订锁和回执,不能 + 从渲染组件直接获得写权限。 + +## Stage-one acceptance checklist + +- [x] 原生对象格式、模块清单、组合配方和投影结果均有机器可读契约。 +- [x] 未登记模块、类型不匹配、组合环、跨账号来源和越界资源必须失败关闭。 +- [x] 表格、分类、对比、仪表盘和柱状图都由同一份执行结果实时渲染。 +- [x] 人类可以选择维度、指标和投影组合,系统重新执行受限组合图。 +- [x] 演示读取冰朔当前 HoloLake 账号的真实知识目录,不以硬编码样例冒充真实运行。 +- [x] 教育初始化频道存在真实可访问的组合视图入口。 +- [x] Node 合同测试、Rust 内核测试、前端测试和生产构建通过。 +- [x] 桌面安装版完成签名安装并在真实界面验收;未完成公证时不得声称公众已获得更新。 +- [x] 每一阶段完成后重新逐条核对本清单;偏差必须在继续前修正。 + +## Installed acceptance + +签名安装版沿主世界、光湖分域、教育行业进入原生组合视图,读取当前账号 129 条真实知识对象, +总量显示约 1.0 MB,并形成 14 个真实分类。五种投影同屏可见;另一次验收把对比和分类卸下, +只保留仪表盘、柱状图和明细表,并把指标从文档数量切换为数据量,原生内核重新执行并生成新回执。 + +界面极快切换指标时曾观察到“左侧待执行配方”先于“右侧上一执行结果”更新,造成单位短暂错配。 +最终实现已让右侧维度、指标、单位和可见视图全部绑定到执行回执,左侧只表示下一次配方;重新构建、 +签名和安装后复验通过。最终桌面二进制 SHA-256 为 +`2a26acae4a706c0ff9919b85f77826a6ca71b650ec22209befd30cd1456c92f3`。 + +Apple 公证和更新器私钥仍未配置,因此本验收不等于公众发布。 + +## Open-source donor rule + +只使用官方仓库和官方文档核验候选。可以学习成熟项目的数据模式、声明式视图语法、无头组件和 +查询计划,但不能整体嵌入重型协作软件,也不能让第三方项目接管 HoloLake 的身份、存储、权限、 +组合执行或 UI 正本。任何复制的代码或新增依赖都必须记录来源、版本、许可证和实际用途。 + +## Rejected alternatives + +- 把 Word、PowerPoint 或 Excel 当作内部原生格式:文件重、结构不稳定,也不适合人格体组合执行。 +- 每种图表保存一份独立数据:会产生副本漂移,破坏“同一数据、多种投影”。 +- 只在 React 中拼一套静态演示:不能证明 HoloLake 系统正在执行组合协议。 +- 让模型自由生成并执行任意图表脚本:绕过模块登记、类型检查、账号边界和资源上限。 +- 复制一整套飞书、钉钉或其他办公系统:体量和权限模型与 HoloLake 的轻量原生目标冲突。 + +## Evidence targets + +- `contracts/native-composition-runtime.json` +- `src-tauri/src/native_composition.rs` +- `src/modules/native-composition/` +- `src/main.tsx` +- `src/styles.css` +- `scripts/native-composition-runtime.test.mjs` diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index 28718d387..4ae8418d7 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -1,7 +1,7 @@ { "schema": "hololake.native-desktop-foundation/v1", "record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001", - "state": "LOCAL_INSTALLED_ACCEPTANCE_PASSED_FOR_PERSONAL_KNOWLEDGE_AND_CODE_SLICES", + "state": "LOCAL_INSTALLED_ACCEPTANCE_PASSED_FOR_PERSONAL_KNOWLEDGE_CODE_AND_EDUCATION_WORKSPACE_SLICES", "canonical_shell": "TAURI_V2_RUST_REACT", "candidate_role": "INSTALLED_LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE", "parallel_product_line_created": false, @@ -36,6 +36,18 @@ "personal_channel_human_projection_integrated": true, "personal_channel_default_task_form_exposed": false, "personal_channel_installed_runtime_acceptance": true, + "persona_channel_body_contract": "contracts/persona-channel-body.json", + "persona_channel_body_is_persona_body_not_simple_feature": true, + "persona_channel_multiple_personas_supported": true, + "persona_channel_thirty_day_reversible_trial_runtime_implemented": true, + "persona_channel_language_contract_gate_implemented": true, + "persona_channel_unsigned_expiry_state": "CONTRACT_REQUIRED_CHANNEL_STOPPED_NO_SILENT_IRREVERSIBLE_ACTIVATION", + "persona_channel_early_contract_immediate_activation_supported": true, + "persona_channel_immutable_millisecond_language_hash_chain_implemented": true, + "persona_channel_active_persona_update_or_delete_allowed": false, + "persona_channel_official_read_access": false, + "channel_growth_projection_contract": "contracts/channel-growth-model.json", + "channel_growth_is_rebuildable_projection_not_persona_body": true, "knowledge_workspace_contract": "contracts/knowledge-workspace.json", "knowledge_workspace_native_source_implemented": true, "knowledge_workspace_tree_read_search_and_folder_import_implemented": true, @@ -56,7 +68,7 @@ "local_runtime_acceptance_receipt": "audit/local-runtime-acceptance-20260815.json", "domain_membrane_pncc_installed_acceptance_receipt": "audit/hololake-0.4.0-domain-membrane-pncc-installed-acceptance-20260816.json", "updater_bootstrap_installed_acceptance_receipt": "audit/hololake-0.4.1-updater-bootstrap-installed-acceptance-20260817.json", - "installed_desktop_path": "/Applications/HoloLake.app", + "installed_desktop_path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake.app", "old_desktop_apps_recoverably_archived": true, "public_developer_id_and_notarized_acceptance": false, "stage_one_implementation_order": [ @@ -142,6 +154,30 @@ "vendor_adapter_matrix_required": false, "first_public_product": "GH-AIOS_GENERAL_AI_OPERATING_PLATFORM", "visible_five_domain_navigation_in_stage_one": true, + "subdomain_industry_routing_contract": "contracts/subdomain-industry-routing.json", + "subdomain_industry_status_source_integrated": true, + "industry_entry_domain": "BRANCH_DOMAIN", + "zero_domain_industry_entry": false, + "education_initialization_channel_source_integrated": true, + "education_workspace_contract": "contracts/education-workspace.json", + "education_native_document_module_implemented": true, + "education_native_table_module_implemented": true, + "education_native_data_cleanup_module_implemented": true, + "education_native_manual_automation_module_implemented": true, + "education_native_composition_module_implemented": true, + "education_workspace_authenticated_account_storage": true, + "education_workspace_restart_readback_rust_tested": true, + "education_document_templates_and_title_filter_source_integrated": true, + "education_table_filter_sort_grid_card_views_and_bulk_paste_source_integrated": true, + "education_open_source_donor_assessment": "contracts/education-open-source-donor-assessment.json", + "education_data_cleanup_preview_and_human_confirmed_save_source_integrated": true, + "education_manual_automation_preview_confirm_revision_lock_and_receipt_source_integrated": true, + "education_native_composition_real_knowledge_typed_dag_and_five_projections_source_integrated": true, + "education_workspace_installed_runtime_acceptance": true, + "education_workspace_ui_route_acceptance": "FIFTH_SLICE_MAIN_WORLD_BRANCH_DOMAIN_EDUCATION_FIVE_NATIVE_MODULES_REAL_129_ROW_FIVE_VIEW_COMPOSITION_AND_RECOMPOSITION_ACCEPTED_READ_ONLY", + "education_workspace_installed_binary_sha256": "4330a31afe654a54e9dbb5f3f180984a5040933d457899763f50b2cc5e54c06d", + "education_native_modules_installed": 5, + "education_open_source_modules_installed": 0, "donors": [ { "path": "../hololake-platform", diff --git a/product-source/hololake-native-desktop/package-lock.json b/product-source/hololake-native-desktop/package-lock.json index a6b47dd91..f527ec24f 100644 --- a/product-source/hololake-native-desktop/package-lock.json +++ b/product-source/hololake-native-desktop/package-lock.json @@ -8,10 +8,20 @@ "name": "hololake-native-desktop", "version": "0.4.1", "dependencies": { + "@fortune-sheet/react": "1.0.4", + "@lexical/history": "0.49.0", + "@lexical/list": "0.49.0", + "@lexical/markdown": "0.49.0", + "@lexical/react": "0.49.0", + "@lexical/rich-text": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/utils": "0.49.0", + "@tanstack/react-table": "8.21.3", "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", "dompurify": "^3.4.13", + "lexical": "0.49.0", "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -750,6 +760,113 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.20", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", + "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@formulajs/formulajs": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@formulajs/formulajs/-/formulajs-2.9.3.tgz", + "integrity": "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g==", + "license": "MIT", + "dependencies": { + "bessel": "^1.0.2", + "jstat": "^1.9.2" + }, + "bin": { + "implementation-stats": "bin/implementation-stats" + } + }, + "node_modules/@fortune-sheet/core": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fortune-sheet/core/-/core-1.0.4.tgz", + "integrity": "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw==", + "license": "MIT", + "dependencies": { + "@fortune-sheet/formula-parser": "^0.2.13", + "dayjs": "^1.11.0", + "immer": "^9.0.12", + "lodash": "^4.17.21", + "numeral": "^2.0.6", + "uuid": "^8.3.2" + } + }, + "node_modules/@fortune-sheet/formula-parser": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/@fortune-sheet/formula-parser/-/formula-parser-0.2.13.tgz", + "integrity": "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw==", + "license": "MIT", + "dependencies": { + "@formulajs/formulajs": "^2.9.3", + "tiny-emitter": "^2.1.0" + } + }, + "node_modules/@fortune-sheet/react": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@fortune-sheet/react/-/react-1.0.4.tgz", + "integrity": "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw==", + "license": "MIT", + "dependencies": { + "@fortune-sheet/core": "^1.0.4", + "@types/regenerator-runtime": "^0.13.6", + "immer": "^9.0.12", + "lodash": "^4.17.21", + "regenerator-runtime": "^0.14.1" + }, + "peerDependencies": { + "react": ">= 18.2", + "react-dom": ">= 18.2" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -800,6 +917,491 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lexical/a11y": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/a11y/-/a11y-0.49.0.tgz", + "integrity": "sha512-ypvO0SI9DCzEAjxkRLq+gNzRqfjg632RCkwu16Hk94PCBrEmcDhx5iUGg3SRJC7WnM2wXC/F/SB8yWDEfq7tEw==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/clipboard": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.49.0.tgz", + "integrity": "sha512-AVKj21xH1qU7JAFA/v0hCoafa+Yti1I7cHG+JQIgc/EqCtP8ePeJyIfTPNN/tXskoCdq++HewQoiSXRwwlocVg==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/html": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/list": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/utils": "0.49.0", + "@types/trusted-types": "^2.0.7", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/code-core": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.49.0.tgz", + "integrity": "sha512-29sXI4ydcN/OyBJNlZXkGKtQXzfiIWet+YN20+wT97zkqityOvRilK6qzTRz9Cd0ungEPlJENqXQhsy6BJeXpg==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/html": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.49.0.tgz", + "integrity": "sha512-LgjNlvRiuO/QF36aG6kfR4ptEvSw587o2+YF6SOsaOB7iw2Z3pqpsKloWSEF4QXaLFI65Li8JjMdQj+BaFMIOA==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.49.0", + "@lexical/link": "0.49.0", + "@lexical/mark": "0.49.0", + "@lexical/table": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "react": ">=18.x", + "react-dom": ">=18.x", + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/dragon": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.49.0.tgz", + "integrity": "sha512-62/4DP5qyX/l4Yf5qRyyQrs9BV725eRU3OmLUW6g7T5xrcHXxAo7tia/NvqjqvXdpvQzyHjWgsy7dMitGITUsw==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/extension": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.49.0.tgz", + "integrity": "sha512-Wv0VsuqxorbxHCK4ms1PwAu6cXIGNLtflq66auF+zwxOtBprkSFV8VzzzdKLOYD2admP0VK6EqVCeGXFK1GggA==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.49.0", + "@lexical/utils": "0.49.0", + "@preact/signals-core": "^1.14.1", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.49.0.tgz", + "integrity": "sha512-aQTpNjrEO5apsaohWGl4ku22EgHpWUzZg5CIzvriS2A1vNmIfzLoCL+lDFpzAwk3DT1Kj1yvGHh5TvhYsb9D8w==", + "license": "MIT", + "dependencies": { + "@lexical/text": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/history": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.49.0.tgz", + "integrity": "sha512-uQdtEd34gIJklXNSdHS2Wko1zxx1xUMVXbiodcLO6a3GeFTE5bKnx6af1zGX78KpYhUQYnHWorKuUvtdZlJPaA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/html": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.49.0.tgz", + "integrity": "sha512-NQqAydKzRjQl7Jx+bTTyC2iuz5uhuDaQX0SSPrdfgaFDCPjqQEejcBkCt1QXnu1CkbVHutZKVGVzljx40Y6y+Q==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/internal": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/internal/-/internal-0.49.0.tgz", + "integrity": "sha512-s+XjPC7Qb39A/Xx9ahcz1s69CPix4ultaqyW+MDG0AXYW2quDr7m0USqReKIAiuoLIWtGN5HW8BwV2Y6t4qr6Q==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/link": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.49.0.tgz", + "integrity": "sha512-CsZVu1OfnPOn6IKWVO5DbmKwIuicGtgWSzrZMdMB8w6yPoMgFyPWeVulhPE7LNz2FhaQCWv+lVVYVAiJrUzJsg==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/html": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/list": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.49.0.tgz", + "integrity": "sha512-zs6wYkxakDRcJO0KmwrPPHgeLLIxzjD+P2CRu+scJCHRuA5e2iSw2iFjH5n/LlWBrlnPMSjeQse4QqsRy9nnqA==", + "license": "MIT", + "dependencies": { + "@lexical/extension": "0.49.0", + "@lexical/html": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/mark": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.49.0.tgz", + "integrity": "sha512-JSYD8UYQQOMT6oDYh1KsLPK3KMGfQzU7E9fJkugKEO0/XebfsmiuNtkLCGDVIZOo8bNHlwb1YVvmswC2bjotMQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/markdown": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.49.0.tgz", + "integrity": "sha512-n7x/3OLdi5R0ztICCn8udxRIWFZFVtmQS3a06Tjkyvm/j/dPlFQ/henKSOFjjX/IqVzQzSmiNaDhqKWxXM5npQ==", + "license": "MIT", + "dependencies": { + "@lexical/code-core": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/link": "0.49.0", + "@lexical/list": "0.49.0", + "@lexical/rich-text": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/text": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/overflow": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.49.0.tgz", + "integrity": "sha512-h5Qjv2SkpI76kaukvKJzvWehG9hwB06GEmAnp6aJaioOzoDcBAeWpmYBebtBUwRm0PNLFe3YICFFLpZ5Tg5VoQ==", + "license": "MIT", + "dependencies": { + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.49.0.tgz", + "integrity": "sha512-l7IuUj9n9CtFfz4Fz6zU6mmO+VkcnvyyebABx+lHevvTa7B6YI5PPVgABFfzdyPanyW/FGs7qpKBf59inAqbkg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.49.0", + "@lexical/dragon": "0.49.0", + "@lexical/extension": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/react": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.49.0.tgz", + "integrity": "sha512-NqXF4i/IBFKVDDLDJYTwmBXE++MQuIrTQs7VTux6IPyDEpyfeFMn1rQTFb949S3f7rs33Rcu1uGtFbODQfS1Iw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.19", + "@lexical/a11y": "0.49.0", + "@lexical/devtools-core": "0.49.0", + "@lexical/dragon": "0.49.0", + "@lexical/extension": "0.49.0", + "@lexical/hashtag": "0.49.0", + "@lexical/history": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/link": "0.49.0", + "@lexical/list": "0.49.0", + "@lexical/mark": "0.49.0", + "@lexical/markdown": "0.49.0", + "@lexical/overflow": "0.49.0", + "@lexical/plain-text": "0.49.0", + "@lexical/rich-text": "0.49.0", + "@lexical/table": "0.49.0", + "@lexical/text": "0.49.0", + "@lexical/utils": "0.49.0", + "@lexical/yjs": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "react": ">=18.x", + "react-dom": ">=18.x", + "typescript": ">=5.2", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "yjs": { + "optional": true + } + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.49.0.tgz", + "integrity": "sha512-LM+d8ULmQvi2Av6UaW4NAIa/Bj9FOo9oh6+dglTzuoFPFQ3sogy5c38i2K9k0ul7aE+bFsXkSmHbu4LkrHIkBQ==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.49.0", + "@lexical/dragon": "0.49.0", + "@lexical/extension": "0.49.0", + "@lexical/html": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/selection": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.49.0.tgz", + "integrity": "sha512-08Vd1+VoC6YnztWOFWVsqF/Hxw5EP8qeL1c7t3+rVCV8revLzXxdQw+vbPX3tgM4fmjOBDUXk6Ws1+rTtsqjcQ==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/table": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.49.0.tgz", + "integrity": "sha512-PG+dxSTBPbUZUY2kBZm0pJuqCEOGom4RrGrc6Jlj/7o0F37k/gGRVprFSRKuf3CqO03QIP6qRovXGDuKb+Exuw==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.49.0", + "@lexical/extension": "0.49.0", + "@lexical/html": "0.49.0", + "@lexical/internal": "0.49.0", + "@lexical/utils": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/text": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.49.0.tgz", + "integrity": "sha512-mowedvbvx0HDaW+ymVdYmWVhueqgauhhqIWaCtbJj33tPk8pOu1BB0pZuJQbOB+1bDnFiZhkJV8W/CvR2M9lEA==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.49.0.tgz", + "integrity": "sha512-Jaa6DERBqxiFOFa49VPRV1WOb7mzRbMZ5U+v+RFagjzTmBhfxDmEkbQQ9nYTU3n3DPfdT8Hkyffextmw04etXg==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.49.0", + "@lexical/selection": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/yjs": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.49.0.tgz", + "integrity": "sha512-uqV3D0AOLqQ1f/Bo6ZtuDLoJYGlBm0RlucNYhNvfMAncEw0f/xHc1pTyXeWjdCI/iWgWR/MV6hgVi26TSPs+Vg==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.49.0", + "@lexical/selection": "0.49.0", + "lexical": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -817,6 +1419,16 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1181,6 +1793,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1516,12 +2161,17 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/regenerator-runtime": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/@types/regenerator-runtime/-/regenerator-runtime-0.13.8.tgz", + "integrity": "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", @@ -1680,6 +2330,15 @@ "node": ">=6.0.0" } }, + "node_modules/bessel": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz", + "integrity": "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -1759,6 +2418,12 @@ "dev": true, "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1915,6 +2580,27 @@ "node": ">=6.9.0" } }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1948,6 +2634,56 @@ "node": ">=6" } }, + "node_modules/jstat": { + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/jstat/-/jstat-1.9.6.tgz", + "integrity": "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug==" + }, + "node_modules/lexical": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.49.0.tgz", + "integrity": "sha512-9V1ZIzGpJEd8rIN+nN7veL4fW4fFWbS66Un4JNqSZB4D5t9euzN9+3+jEXy83FjNjNy0MiiUI3+DQaGCLYko0w==", + "license": "MIT", + "dependencies": { + "@lexical/internal": "0.49.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2016,6 +2752,15 @@ "node": ">=18" } }, + "node_modules/numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -2117,6 +2862,12 @@ "node": ">=0.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -2210,6 +2961,18 @@ "dev": true, "license": "MIT" }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2258,7 +3021,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2299,6 +3062,19 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2487,6 +3263,24 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yjs": { + "version": "13.6.32", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz", + "integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/product-source/hololake-native-desktop/package.json b/product-source/hololake-native-desktop/package.json index 5df834570..a6f5a54f4 100644 --- a/product-source/hololake-native-desktop/package.json +++ b/product-source/hololake-native-desktop/package.json @@ -11,10 +11,20 @@ "tauri": "tauri" }, "dependencies": { + "@fortune-sheet/react": "1.0.4", + "@lexical/history": "0.49.0", + "@lexical/list": "0.49.0", + "@lexical/markdown": "0.49.0", + "@lexical/react": "0.49.0", + "@lexical/rich-text": "0.49.0", + "@lexical/selection": "0.49.0", + "@lexical/utils": "0.49.0", + "@tanstack/react-table": "8.21.3", "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", "dompurify": "^3.4.13", + "lexical": "0.49.0", "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -27,5 +37,8 @@ "typescript": "~5.9.3", "vite": "^7.3.5", "vitest": "^4.0.18" + }, + "overrides": { + "uuid": "11.1.1" } } diff --git a/product-source/hololake-native-desktop/scripts/authenticated-main-world-navigation.test.mjs b/product-source/hololake-native-desktop/scripts/authenticated-main-world-navigation.test.mjs new file mode 100644 index 000000000..6d86af980 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/authenticated-main-world-navigation.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const frontend = fs.readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8') +const architecture = JSON.parse( + fs.readFileSync(new URL('../../../routing/hololake-current-architecture.json', import.meta.url), 'utf8'), +) + +test('returning to the main world preserves the authenticated session', () => { + const handler = frontend.match(/const returnToMainWorld = \(\) => \{([\s\S]*?)\n \}/)?.[1] || '' + + assert.match(handler, /setShowMainWorld\(true\)/) + assert.doesNotMatch(handler, /signOutRepo|setRepoLogin\(null\)|sign_out_code_repo_login/) + assert.match(frontend, /if \(!repoLogin \|\| showMainWorld\)/) +}) + +test('main-world return and account sign-out remain separate visible actions', () => { + assert.match(frontend, />返回主世界进入所属域退出账号 fs.readFileSync(new URL('../' + path, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/education-workspace.json')) +const donors = JSON.parse(read('contracts/education-open-source-donor-assessment.json')) +const industry = JSON.parse(read('contracts/subdomain-industry-routing.json')) +const rust = read('src-tauri/src/education_workspace.rs') +const translation = read('src-tauri/src/education_translation.rs') +const lib = read('src-tauri/src/lib.rs') +const ui = read('src/main.tsx') +const documentEngine = read('src/modules/education-document-engine.tsx') +const channelDocumentEngine = read('src/modules/channel-workbench/document-engine.tsx') +const channelSpreadsheetEngine = read('src/modules/channel-workbench/spreadsheet-engine.tsx') +const channelSpreadsheetModel = read('src/modules/channel-workbench/spreadsheet-model.ts') +const moduleRuntime = JSON.parse(read('contracts/channel-module-runtime.json')) +const marketplaceDevelopmentCatalog = JSON.parse(read('contracts/official-module-marketplace-development-catalog.json')) +const adaptiveRendering = read('src/modules/education-adaptive-rendering.ts') +const dataCleanup = read('src/modules/education-data.ts') + +test('education package consumes the built-in account-scoped channel workbench', () => { + assert.equal(contract.domain_entry, 'BRANCH_DOMAIN') + assert.equal(contract.channel_id, 'GH-EDU-INIT-001') + assert.equal(contract.native_storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE') + assert.equal(contract.native_storage.engine, 'SQLITE') + assert.equal(contract.native_storage.authenticated_account_required, true) + assert.equal(contract.native_storage.cross_account_projection_allowed, false) + assert.equal(contract.native_storage.restart_readback_required, true) + assert.equal(contract.document_module.state, 'DEVELOPMENT_FEATURE_MOUNTED') + assert.equal(contract.table_module.state, 'DEVELOPMENT_FEATURE_MOUNTED') + assert.equal(contract.foundation_dependency.module_id, 'hololake.builtin.channel-workbench') + assert.match(contract.document_module.engine, /^CHANNEL_WORKBENCH_LEXICAL_0_49_0/) + assert.match(contract.table_module.engine, /^CHANNEL_WORKBENCH_FORTUNE_SHEET_1_0_4/) + assert.equal(contract.table_module.sensitive_field_visibility.local_default, 'VISIBLE') + assert.equal(contract.table_module.sensitive_field_visibility.human_can_hide, true) + assert.equal(contract.table_module.sensitive_field_visibility.human_can_show_again, true) +}) + +test('education workspace commands require the authenticated storage root', () => { + assert.match(rust, /account_storage_root\(app, "education-workspace-v1"\)/) + for (const command of [ + 'get_education_workspace_snapshot', + 'create_education_document', + 'read_education_document', + 'save_education_document', + 'archive_education_document', + 'create_education_table', + 'read_education_table', + 'save_education_table', + 'archive_education_table', + 'create_education_automation_rule', + 'save_education_automation_rule', + 'archive_education_automation_rule', + 'preview_education_automation_rule', + 'execute_education_automation_rule', + ]) { + assert.match(lib, new RegExp('education_workspace::' + command)) + assert.ok(ui.includes("'" + command + "'") || ui.includes('"' + command + '"')) + } + assert.doesNotMatch(ui, /localStorage.*education/i) +}) + +test('one built-in foundation and five development features are reachable from the education channel', () => { + const education = industry.industries.find((item) => item.key === 'EDUCATION') + assert.equal(education.initialization_channel.installed_open_source_modules, 0) + assert.equal(education.initialization_channel.installed_native_modules, 1) + assert.equal(education.initialization_channel.development_mounted_modules, 1) + assert.ok(education.initialization_channel.module_slots.every((slot) => slot.state === 'DEVELOPMENT_MOUNTED')) + assert.equal(education.initialization_channel.module_slots[1].state_label, '开发态模块 · 使用频道内置工作表引擎') + assert.match(ui, /worldStage === 'educationDocuments'/) + assert.match(ui, /worldStage === 'educationTables'/) + assert.match(ui, /教育文档/) + assert.match(ui, /教育表格数据/) + assert.match(ui, /worldStage === 'educationAutomation'/) + assert.match(ui, /教育自动化/) + assert.match(ui, /worldStage === 'educationComposition'/) + assert.match(ui, /原生组合视图/) +}) + +test('native persistence enforces revision conflicts and bounded table data', () => { + assert.match(rust, /HOLOLAKE_EDUCATION_DOCUMENT_REVISION_CONFLICT/) + assert.match(rust, /HOLOLAKE_EDUCATION_TABLE_REVISION_CONFLICT/) + assert.match(rust, /MAX_TABLE_COLUMNS: usize = 30/) + assert.match(rust, /MAX_TABLE_ROWS: usize = 1_000/) + assert.match(rust, /MAX_CELL_BYTES: usize = 10_000/) + assert.match(rust, /archived = 1/) +}) + +test('education documents expose formal templates and real title filtering', () => { + assert.equal(contract.state, 'DEVELOPMENT_MODULE_SOURCE_MOUNTED_ACCEPTANCE_PENDING') + assert.equal(contract.package.official_module_number, null) + assert.equal(contract.package.registration_state, 'PENDING_FUNCTIONAL_ACCEPTANCE_AND_NUMBER_REGISTRATION') + assert.ok(contract.document_module.capabilities.includes('FORMAL_TEACHING_TEMPLATES')) + assert.ok(contract.document_module.capabilities.includes('TITLE_DIRECTORY_FILTER')) + assert.match(ui, /EDUCATION_DOCUMENT_TEMPLATES/) + assert.match(ui, /教学计划/) + assert.match(ui, /教育项目会议纪要/) + assert.match(ui, /学员阶段复盘/) + assert.match(ui, /检索教育文档标题/) + for (const capability of [ + 'CREATE_COPY', + 'EXPLICIT_READ_AND_EDIT_MODES', + 'HUMAN_SETTINGS_MENU', + 'BASIC_FORMATTING_TOOLBAR', + 'NATIVE_READING_CANVAS', + 'EXPORT_MARKDOWN_AND_HTML', + ]) assert.ok(contract.document_module.capabilities.includes(capability)) + assert.match(ui, /文档设置/) + assert.match(ui, /编辑与重命名/) + assert.match(ui, /创建副本/) + assert.match(ui, /移到可恢复归档/) + assert.match(ui, /educationDocumentMode === 'edit'/) + assert.match(documentEngine, /ChannelDocumentEngine/) + assert.match(channelDocumentEngine, /频道内置文档引擎工具/) +}) + +test('education table has four engine-backed views, filtering, sorting and bounded bulk paste', () => { + for (const capability of [ + 'CELL_CONTENT_FILTER', + 'COLUMN_SORT_ASCENDING_DESCENDING_OR_SOURCE_ORDER', + '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', + ]) assert.ok(contract.table_module.capabilities.includes(capability)) + assert.match(ui, /parseEducationGridPaste/) + assert.match(ui, /pasteEducationTableGrid/) + assert.match(ui, />工作表<\/button>/) + assert.match(channelSpreadsheetEngine, /Workbook/) + assert.match(channelSpreadsheetEngine, /showFormulaBar/) + assert.match(channelSpreadsheetEngine, /conditionFormat/) + assert.match(channelSpreadsheetModel, /cell\.f/) + assert.match(ui, /分类看板/) + assert.match(ui, /数据概览/) + assert.match(ui, /renderEducationBoard/) + assert.match(ui, /renderEducationDashboard/) + assert.match(ui, /动态分类对比/) + assert.match(ui, /筛选教育表格数据/) + assert.match(ui, /升序 ↑/) + assert.match(ui, /降序 ↓/) + assert.match(ui, /getPaginationRowModel/) + assert.match(ui, /const educationTableEngineActive = worldStage === 'educationTables'/) + assert.match(ui, /educationTableEngineActive \? activeEducationTable\?\.rows \|\| \[\] : \[\]/) + assert.match(adaptiveRendering, /EDUCATION_EDITABLE_CELL_BUDGET = 240/) + assert.match(adaptiveRendering, /function educationEditablePageSize/) + assert.match(adaptiveRendering, /Math\.floor\(EDUCATION_EDITABLE_CELL_BUDGET \/ boundedColumns\)/) + assert.match(adaptiveRendering, /buildEducationBaselineRenderPlan/) + assert.match(adaptiveRendering, /validateEducationModelRenderProposal/) + assert.match(adaptiveRendering, /MODEL_PROPOSAL_REJECTED/) + assert.match(adaptiveRendering, /WAITING_HUMAN_CONFIRMATION/) + assert.match(adaptiveRendering, /已优雅退回本地基础算法/) + assert.match(ui, /基础算法自适应回执/) + assert.match(ui, /教育表格分页/) + assert.match(ui, /Math\.min\(30/) + assert.match(ui, /Math\.min\(1000/) + assert.ok(contract.table_module.capabilities.includes('HUMAN_SETTINGS_MENU')) + assert.ok(contract.table_module.capabilities.includes('CREATE_COPY')) + assert.match(ui, /表格设置/) + assert.match(ui, /duplicateEducationTable/) + assert.match(ui, /重命名与编辑/) + assert.match(ui, /isSensitiveEducationColumn/) + assert.match(ui, /账号\|账户\|手机号/) + assert.match(ui, /联系qq\|qq号/) + assert.match(ui, /useState\(true\).*educationSensitiveVisible|educationSensitiveVisible, setEducationSensitiveVisible\] = useState\(true\)/) + assert.match(ui, /隐藏敏感字段/) + assert.match(ui, /显示敏感字段/) + assert.doesNotMatch(ui, /setEducationSensitiveVisible\(false\)/) + assert.match(ui, /type=\{sensitive && !educationSensitiveVisible \? 'password' : 'text'\}/) +}) + +test('external tables are profiled before atomic native registration and can be translated back out', () => { + assert.equal(contract.content_translation_layer.state, 'DETERMINISTIC_RUNTIME_READY_MODEL_ASSIST_SLOT_RESERVED') + assert.equal(contract.content_translation_layer.source_file_mutation_allowed, false) + assert.equal(contract.content_translation_layer.silent_truncation_allowed, false) + assert.equal(contract.content_translation_layer.local_import_without_model_api, 'ENABLED_FOR_ALL_SUPPORTED_FORMATS') + assert.equal(contract.content_translation_layer.semantic_focus.current_mode, 'DETERMINISTIC_BASELINE_ONLY') + assert.equal(contract.content_translation_layer.semantic_focus.low_confidence_behavior, 'ASK_HUMAN_TO_SELECT_PRIMARY_MEASURE') + assert.equal(contract.content_translation_layer.adaptive_rendering.current_mode, 'DETERMINISTIC_BASELINE_WITH_EXPLAINABLE_DEGRADATION') + assert.equal(contract.content_translation_layer.adaptive_rendering.editable_cell_budget, 240) + assert.equal(contract.content_translation_layer.adaptive_rendering.local_baseline_always_available, true) + assert.equal(contract.content_translation_layer.adaptive_rendering.model_adjustment.human_confirmation_before_rule_activation, true) + assert.equal(contract.content_translation_layer.adaptive_rendering.model_adjustment.model_can_mutate_source_data, false) + assert.equal(contract.content_translation_layer.adaptive_rendering.model_adjustment.failure_behavior, 'RETURN_TO_LOCAL_BASELINE_WITH_RECEIPT') + assert.deepEqual(contract.content_translation_layer.model_assist.learning_scopes, ['PRIVATE_ONLY', 'SHARE_ANONYMIZED_RULE']) + assert.match(translation, /PROFILED_BEFORE_WRITE/) + assert.match(translation, /import_table_batch_at/) + assert.match(translation, /source_preserved_read_only: true/) + assert.match(translation, /HOLOLAKE_MODEL_RECOGNITION_API\/v1/) + assert.match(translation, /infer_semantic_focus/) + assert.match(translation, /primary_measure_column/) + assert.match(rust, /education_import_registry/) + assert.match(ui, /导入外部表格/) + assert.match(ui, /导出 Excel/) + assert.match(ui, /识别边界回执/) + assert.match(ui, /本地导入正常可用/) +}) + +test('audited translation libraries and headless engines stay bounded inside the HoloLake product shell', () => { + assert.equal(donors.state, 'FOUR_TRANSLATION_FAMILIES_TWO_HEADLESS_FAMILIES_AND_ONE_REAL_SPREADSHEET_ENGINE_INTEGRATED') + assert.equal(donors.current_slice.external_translation_library_families_added, 4) + assert.equal(donors.current_slice.external_headless_engine_families_added, 2) + assert.equal(donors.current_slice.real_spreadsheet_engine_families_added, 1) + assert.equal(donors.current_slice.direct_frontend_engine_packages_added, 10) + assert.equal(donors.current_slice.external_source_files_copied, 0) + assert.equal(donors.current_slice.source_research_route, 'LOCAL_DIRECT_TO_BS_SG_002_THEN_EXTERNAL_DRIVE') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'Calamine').decision, 'INTEGRATE_PINNED_0_26_1_READ_ONLY_NO_OFFICE_RUNTIME') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'rust_xlsxwriter').decision, 'INTEGRATE_PINNED_0_64_2_WRITE_NEW_XLSX_ONLY') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'AppFlowy').decision, 'DO_NOT_EMBED_WHOLE_APPLICATION_STUDY_WORKSPACE_AND_DATABASE_INTERACTION_LOGIC_ONLY') + assert.match(donors.candidates.find((candidate) => candidate.name === 'Lexical').decision, /^INTEGRATE_PINNED_0_49_0/) + assert.equal(donors.candidates.find((candidate) => candidate.name === 'TanStack Table').decision, 'INTEGRATE_PINNED_8_21_3_SORT_FILTER_GROUP_AGGREGATE_AND_PAGINATION_ROW_MODELS_ONLY') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'Univer').decision, 'SOURCE_STUDY_ONLY_DO_NOT_EMBED_WHOLE_APPLICATION_DEFER_FORMULA_AND_ADVANCED_GRID_PARTS') + assert.match(donors.candidates.find((candidate) => candidate.name === 'FortuneSheet').decision, /^INTEGRATE_PINNED_1_0_4/) + assert.equal(donors.candidates.find((candidate) => candidate.name === 'Glide Data Grid').decision, 'SOURCE_STUDY_ONLY_GRID_IS_NOT_A_SPREADSHEET_OR_FORMULA_ENGINE') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'Vega-Lite').observed_license, 'BSD-3-Clause') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'Observable Plot').observed_license, 'ISC') + assert.equal(donors.candidates.find((candidate) => candidate.name === 'Apache ECharts').decision, 'DO_NOT_IMPORT_FOR_STAGE_ONE_FIVE_NATIVE_VIEWS_DO_NOT_JUSTIFY_RUNTIME_WEIGHT') + assert.match(ui, /useReactTable/) + assert.match(ui, /getGroupedRowModel/) + assert.match(ui, /lazy\(\(\) => import\('\.\/modules\/education-document-engine'\)\)/) + assert.match(channelDocumentEngine, /LexicalComposer/) + assert.match(channelDocumentEngine, /HistoryPlugin/) + assert.match(channelDocumentEngine, /\$convertFromMarkdownString/) + assert.match(channelDocumentEngine, /\$convertToMarkdownString/) +}) + +test('official module marketplace boundary is honest before education acceptance', () => { + assert.equal(moduleRuntime.built_in_foundation.module_id, 'hololake.builtin.channel-workbench') + assert.equal(moduleRuntime.built_in_foundation.marketplace_registration, 'NOT_A_MARKETPLACE_MODULE') + assert.equal(moduleRuntime.current_reality.official_marketplace_service_connected, false) + const educationPackage = marketplaceDevelopmentCatalog.packages.find((item) => item.package_key === 'hololake.official.education-workbench') + assert.equal(educationPackage.official_module_number, null) + assert.equal(educationPackage.distribution_state, 'NOT_PUBLISHED') + assert.ok(educationPackage.acceptance_required.includes('CREATE_EDIT_FORMULA_SAVE_REOPEN_SPREADSHEET')) +}) + +test('native composition uses one real account dataset for five read-only human projections', () => { + assert.equal(contract.composition_module.state, 'DEVELOPMENT_FEATURE_MOUNTED') + assert.equal(contract.composition_module.source, 'CURRENT_AUTHENTICATED_ACCOUNTS_REAL_KNOWLEDGE_CATALOG') + assert.equal(contract.composition_module.read_only, true) + assert.equal(contract.composition_module.hardcoded_sample_data_used, false) + for (const capability of [ + 'NATIVE_TYPED_OBJECT', + 'REGISTERED_MODULE_EXECUTION_GRAPH', + 'DASHBOARD_PROJECTION', + 'COMPARISON_PROJECTION', + 'VERTICAL_BAR_PROJECTION', + 'CLASSIFICATION_PROJECTION', + 'TABLE_PROJECTION', + 'ONE_EXECUTION_RESULT_MULTIPLE_HUMAN_VIEWS', + ]) assert.ok(contract.composition_module.capabilities.includes(capability)) + assert.match(lib, /native_composition::execute_knowledge_native_composition/) + assert.match(ui, /execute_knowledge_native_composition/) +}) + +test('education data cleanup previews bounded deterministic rules and requires human confirmation before save', () => { + assert.equal(contract.data_cleanup_module.state, 'DEVELOPMENT_FEATURE_MOUNTED') + assert.equal(contract.data_cleanup_module.automatic_destructive_cleanup_allowed, false) + assert.equal(contract.data_cleanup_module.cross_account_data_allowed, false) + for (const capability of [ + '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', + ]) assert.ok(contract.data_cleanup_module.capabilities.includes(capability)) + assert.equal(contract.data_cleanup_module.preview_change_limit, 12) + assert.equal(contract.data_cleanup_module.preview_cell_value_character_limit, 120) + assert.equal(contract.data_cleanup_module.full_table_analysis_is_limited_by_preview, false) + assert.match(dataCleanup, /EDUCATION_CLEANUP_CHANGE_PREVIEW_LIMIT = 12/) + assert.match(dataCleanup, /changePreview\.length < EDUCATION_CLEANUP_CHANGE_PREVIEW_LIMIT/) + assert.match(dataCleanup, /singleLine\.length > 120/) + assert.match(ui, /analyzeEducationTableData/) + assert.match(ui, /const educationDataAnalysis = useMemo/) + assert.match(ui, /已分析全表/) + assert.match(ui, /education-cleanup-change-list/) + assert.doesNotMatch(ui, /analysis\.cleanedRows\.slice\(0, 12\)/) + assert.match(ui, /educationData/) + assert.match(ui, /window\.confirm\(`将整理/) + assert.match(ui, /确认整理并保存/) + assert.match(ui, /保存前不写入数据库/) +}) + +test('education automation is manual, revision-locked, previewed and receipted', () => { + assert.equal(contract.automation_module.state, 'DEVELOPMENT_FEATURE_MOUNTED') + assert.equal(contract.automation_module.background_or_scheduled_execution_allowed, false) + assert.equal(contract.automation_module.cross_account_data_allowed, false) + assert.equal(contract.automation_module.model_execution_authority_granted, false) + for (const capability of [ + 'CREATE_AND_SAVE_RULE', + 'PREVIEW_MATCHED_ROWS_AND_CHANGED_CELLS', + 'HUMAN_CONFIRMATION_BEFORE_EXECUTION', + 'RULE_AND_TABLE_REVISION_LOCK', + 'SIGNED_PREVIEW_TOKEN', + 'ATOMIC_TABLE_WRITE_AND_EXECUTION_RECEIPT', + ]) assert.ok(contract.automation_module.capabilities.includes(capability)) + assert.match(rust, /education_automation_rules/) + assert.match(rust, /education_automation_runs/) + assert.match(rust, /transaction_with_behavior/) + assert.match(rust, /HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_TOKEN_MISMATCH/) + assert.match(ui, /预览执行范围/) + assert.match(ui, /确认运行并生成回执/) + assert.match(ui, /系统不会在后台自行运行/) +}) diff --git a/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs index dbdfb6951..b1751d7b5 100644 --- a/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs +++ b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs @@ -65,6 +65,7 @@ test('the product surface exposes a real knowledge workbench and browsable code 'read_knowledge_document', 'search_knowledge', 'save_knowledge_document', + 'update_knowledge_organization', 'select_and_import_knowledge_folder', 'get_code_channel_snapshot', 'clone_code_channel', @@ -81,3 +82,21 @@ test('the product surface exposes a real knowledge workbench and browsable code assert.match(ui, /源文件/) assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/) }) + +test('knowledge pages have one native category and tag organization layer without rewriting source documents', () => { + assert.equal(knowledgeContract.organization.schema, 'hololake.knowledge-organization/v1') + assert.equal(knowledgeContract.organization.document_body_rewritten, false) + assert.equal(knowledgeContract.organization.original_directory_moved, false) + assert.equal(knowledgeContract.organization.explicit_category_override, true) + assert.equal(knowledgeContract.organization.optimistic_content_hash_lock, true) + assert.match(knowledgeRust, /organization-v1\.json/) + assert.match(knowledgeRust, /HOLOLAKE_KNOWLEDGE_ORGANIZATION_CONFLICT/) + assert.match(knowledgeRust, /MAX_TAGS_PER_DOCUMENT: usize = 12/) + assert.match(knowledgeRust, /archive_native_entry/) + assert.match(knowledgeRust, /ARCHIVE_COMMIT/) + assert.match(ui, /知识结构/) + assert.match(ui, /分类与标签/) + assert.match(ui, /保存整理/) + assert.match(ui, /匹配页面/) + assert.match(ui, /update_knowledge_organization/) +}) diff --git a/product-source/hololake-native-desktop/scripts/native-composition-runtime.test.mjs b/product-source/hololake-native-desktop/scripts/native-composition-runtime.test.mjs new file mode 100644 index 000000000..662b52fac --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/native-composition-runtime.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (path) => fs.readFileSync(new URL('../' + path, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/native-composition-runtime.json')) +const rust = read('src-tauri/src/native_composition.rs') +const knowledge = read('src-tauri/src/knowledge_base.rs') +const ui = read('src/modules/native-composition/index.tsx') +const lib = read('src-tauri/src/lib.rs') + +test('native objects and composition recipes are bounded system contracts', () => { + assert.equal(contract.internal_object.schema, 'hololake.native-object/v1') + assert.equal(contract.internal_object.script_allowed, false) + assert.equal(contract.internal_object.html_allowed, false) + assert.equal(contract.recipe.registered_modules_only, true) + assert.equal(contract.recipe.directed_acyclic_graph_required, true) + assert.equal(contract.recipe.current_account_only, true) + assert.match(rust, /HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED/) + assert.match(rust, /HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH/) + assert.match(rust, /HOLOLAKE_COMPOSITION_CYCLE_REJECTED/) +}) + +test('the native core reads only the authenticated HoloLake knowledge snapshot', () => { + assert.match(knowledge, /snapshot_for_native_composition/) + assert.match(knowledge, /account_storage_root\(app, "knowledge-v1"\)/) + assert.match(rust, /snapshot_for_native_composition/) + assert.match(rust, /source_is_real_account_data: true/) + assert.match(lib, /native_composition::execute_knowledge_native_composition/) +}) + +test('one execution result can be rendered through every stage-one human projection', () => { + assert.deepEqual(contract.human_projection.views, ['DASHBOARD', 'COMPARISON', 'VERTICAL_BAR', 'CLASSIFICATION', 'TABLE']) + assert.equal(contract.human_projection.shares_one_execution_result, true) + assert.equal(contract.human_projection.owns_source_data, false) + assert.equal(contract.human_projection.direct_write_authority, false) + for (const label of ['仪表盘', '对比', '柱状图', '分类', '明细表']) assert.match(ui, new RegExp(label)) + assert.match(ui, /projection\.nativeObject\.rows/) + assert.match(ui, /projection\?\.groups/) +}) diff --git a/product-source/hololake-native-desktop/scripts/persona-channel-body.test.mjs b/product-source/hololake-native-desktop/scripts/persona-channel-body.test.mjs new file mode 100644 index 000000000..759fd7648 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/persona-channel-body.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (path) => fs.readFileSync(new URL('../' + path, import.meta.url), 'utf8') +const body = JSON.parse(read('contracts/persona-channel-body.json')) +const growth = JSON.parse(read('contracts/channel-growth-model.json')) +const foundation = JSON.parse(read('foundation.json')) +const rust = read('src-tauri/src/persona_channel_body.rs') +const lib = read('src-tauri/src/lib.rs') + +test('the user channel is the multi-persona body, not a personalization feature', () => { + assert.equal(body.ontology.user_channel_is_simple_feature, false) + assert.equal(body.ontology.user_channel_is_persona_body, true) + assert.equal(body.ontology.multiple_personas_per_channel, true) + assert.equal(body.ontology.language_is_persona_growth_source, true) + assert.equal(growth.role, 'REBUILDABLE_DERIVED_PROJECTION_OF_PERSONA_CHANNEL_BODY') + assert.equal(foundation.persona_channel_body_is_persona_body_not_simple_feature, true) + assert.equal(foundation.channel_growth_is_rebuildable_projection_not_persona_body, true) +}) + +test('the 30-day trial is reversible but real activation requires the language contract', () => { + assert.equal(body.trial.duration_days, 30) + assert.equal(body.trial.persona_and_trial_language_may_be_rolled_back_or_deleted, true) + assert.equal(body.trial.irreversible_activation_without_language_contract_allowed, false) + assert.equal(body.trial.early_signature_may_activate_real_trajectory_immediately, true) + assert.equal(body.trial.unsigned_at_trial_end, 'CONTRACT_REQUIRED_CHANNEL_STOPPED') + assert.equal(body.trial.private_channel_features_after_unsigned_expiry, 'UNAVAILABLE_BECAUSE_NO_ACTIVE_PERSONA_BODY') + assert.match(rust, /TRIAL_DURATION_MS: i64 = 30/) + assert.match(rust, /CONTRACT_REQUIRED_CHANNEL_STOPPED/) + assert.match(rust, /activate_immediately/) + assert.match(rust, /HOLOLAKE_LANGUAGE_CONTRACT_EXACT_ACCEPTANCE_REQUIRED/) +}) + +test('accepted personas and language become an append-only millisecond hash chain', () => { + assert.equal(body.real_trajectory.timestamp_precision, 'UNIX_MILLISECONDS') + assert.equal(body.real_trajectory.ledger, 'APPEND_ONLY_SHA256_CHAIN') + assert.equal(body.real_trajectory.update_allowed, false) + assert.equal(body.real_trajectory.delete_allowed, false) + assert.equal(body.real_trajectory.correction_method, 'APPEND_A_NEW_LANGUAGE_OR_RECEIPT_EVENT') + assert.match(rust, /immutable_language_no_update/) + assert.match(rust, /immutable_language_no_delete/) + assert.match(rust, /active_persona_no_delete/) + assert.match(rust, /language_hash/) + assert.match(lib, /persona_channel_body::append_persona_language/) +}) + +test('the body remains local and official cannot read it unless a derived anonymized layer is explicitly shared', () => { + assert.equal(body.privacy_and_sharing.default, 'ACCOUNT_SCOPED_LOCAL_ONLY') + assert.equal(body.privacy_and_sharing.hololake_official_read_access, false) + assert.equal(body.privacy_and_sharing.raw_language_automatic_share, false) + assert.equal(growth.cross_device_or_shared_learning.explicit_opt_in_required, true) + assert.equal(growth.cross_device_or_shared_learning.raw_growth_chain_may_be_shared, false) +}) diff --git a/product-source/hololake-native-desktop/scripts/subdomain-industry-channel.test.mjs b/product-source/hololake-native-desktop/scripts/subdomain-industry-channel.test.mjs new file mode 100644 index 000000000..2a5fbe8bf --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/subdomain-industry-channel.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const contract = JSON.parse( + fs.readFileSync(new URL('../contracts/subdomain-industry-routing.json', import.meta.url), 'utf8'), +) +const frontend = fs.readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8') +const architecture = JSON.parse( + fs.readFileSync(new URL('../../../routing/hololake-current-architecture.json', import.meta.url), 'utf8'), +) + +test('human identity numbers and industry routing numbers remain separate', () => { + assert.equal(contract.numbering.human_number_role, 'SUBJECT_IDENTITY_ONLY') + assert.equal(contract.numbering.industry_number_role, 'SYSTEM_ROUTING_ONLY') + assert.equal(contract.numbering.human_number_mutation_by_industry, false) + assert.equal(contract.numbering.industry_order_is_industry_number, false) + assert.equal(contract.numbering.unregistered_industry_number_behavior, 'DISPLAY_PENDING_AND_DO_NOT_INVENT') +}) + +test('subdomain owns the locked industry order and honest development states', () => { + assert.equal(contract.domain, 'BRANCH_DOMAIN') + assert.equal(contract.entry_surface, 'PUBLIC_MAIN_WORLD_SUBDOMAIN') + assert.deepEqual( + contract.industries.map(({ key, state }) => [key, state]), + [ + ['WEB_NOVEL', 'PLANNED'], + ['PET', 'PLANNED'], + ['EDUCATION', 'IN_DEVELOPMENT'], + ], + ) + assert.equal(contract.industries[0].industry_number, 'IND-WEBNOVEL-001') + assert.equal(contract.industries[1].industry_number, null) + assert.equal(contract.industries[2].industry_number, null) +}) + +test('education initialization channel exposes the built-in foundation and development package features', () => { + const education = contract.industries.find((industry) => industry.key === 'EDUCATION') + assert.equal(education.initialization_channel.state, 'DEVELOPMENT_MODULE_MOUNTED') + assert.equal(education.initialization_channel.channel_id, 'GH-EDU-INIT-001') + assert.equal(education.initialization_channel.module_loading, 'HOT_PLUG_ON_DEMAND') + assert.equal(education.initialization_channel.module_slots.length, 5) + assert.deepEqual( + education.initialization_channel.module_slots.map((slot) => slot.state), + [ + 'DEVELOPMENT_MOUNTED', + 'DEVELOPMENT_MOUNTED', + 'DEVELOPMENT_MOUNTED', + 'DEVELOPMENT_MOUNTED', + 'DEVELOPMENT_MOUNTED', + ], + ) + assert.equal(education.initialization_channel.installed_native_modules, 1) + assert.equal(education.initialization_channel.development_mounted_modules, 1) + assert.equal(education.initialization_channel.installed_open_source_modules, 0) +}) + +test('the public main world routes through subdomain to industries and education', () => { + assert.match(frontend, /worldStage === 'subdomainIndustries'/) + assert.match(frontend, /worldStage === 'educationChannel'/) + assert.match(frontend, /gate\.domain === 'BRANCH_DOMAIN'/) + assert.match(frontend, /光湖分域 · 行业系统/) + assert.match(frontend, /网文 · 待开发 \/ 宠物 · 待开发 \/ 教育 · 开发中/) + assert.match(frontend, /教育行业初始化频道/) + assert.match(frontend, /频道基础文档与工作表引擎已内置/) + assert.match(frontend, /验收前不冒充已登记、已发布的商城模块/) + assert.doesNotMatch(frontend, /光湖零域 · 行业系统/) + assert.equal(architecture.current_native_edge.industry_entry_domain, 'BRANCH_DOMAIN') + assert.equal(architecture.current_native_edge.zero_domain_industry_entry, 'ABSENT_BY_CURRENT_CORRECTION') + assert.equal( + architecture.current_native_edge.education_initialization_channel, + 'BUILT_IN_CHANNEL_WORKBENCH_WITH_DEVELOPMENT_EDUCATION_MODULE', + ) +}) diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index eb5ebf94c..ca749dad2 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,13 +1550,17 @@ name = "hololake-native-desktop" version = "0.4.1" dependencies = [ "base64 0.22.1", + "calamine", + "csv", "dirs", + "encoding_rs", "fs2", "futures-util", "interprocess", "reqwest", "ring", "rusqlite", + "rust_xlsxwriter", "serde", "serde_json", "tauri", @@ -2833,7 +2892,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -2980,6 +3039,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" @@ -3350,6 +3419,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" @@ -4310,7 +4390,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5846,6 +5926,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" @@ -5863,3 +5972,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 38634cc57..8e6c4fcab 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"] } @@ -33,6 +36,7 @@ url = "2" reqwest = { version = "0.13.2", default-features = false, features = ["cookies", "form", "json", "rustls", "stream"] } tokio = { version = "1", features = ["time"] } futures-util = "0.3" +rust_xlsxwriter = "=0.64.2" [target.'cfg(windows)'.dependencies] widestring = "1" diff --git a/product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs b/product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs new file mode 100644 index 000000000..7c8fb9dd2 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 当前账号私人频道的长期成长模型。 +//! +//! 这里只保存最小化的行为元数据,不保存文档正文、表格单元格、提示词、对话原文或凭据。 +//! 基础算法可以持续形成模块亲和度;模型 API 未来只能提交适配提案,不能绕过本地校验和人类确认。 + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const GROWTH_SCHEMA: &str = "hololake.channel-growth-model/v1"; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordChannelGrowthEventInput { + pub event_kind: String, + pub module_id: String, + pub source: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChannelGrowthSharingInput { + pub sharing_mode: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelGrowthControls { + pub sharing_mode: String, + pub official_read_access: bool, + pub history_mutation_allowed: bool, + pub structural_change_requires_human_confirmation: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelModuleAffinity { + pub module_id: String, + pub use_count: i64, + pub score: f64, + pub last_used_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelGrowthSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub controls: ChannelGrowthControls, + pub observed_event_count: i64, + pub persona_collaboration_event_count: i64, + pub module_affinities: Vec, + pub recommended_module_order: Vec, + pub integrity_state: &'static str, + pub last_event_hash: String, + pub model_api_state: &'static str, + pub model_proposal_policy: &'static str, + pub data_boundary: &'static str, +} + +#[tauri::command] +pub async fn get_channel_growth_snapshot(app: AppHandle) -> Result { + let database = channel_growth_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn record_channel_growth_event( + app: AppHandle, + input: RecordChannelGrowthEventInput, +) -> Result { + let database = channel_growth_database(&app)?; + tauri::async_runtime::spawn_blocking(move || record_event_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn update_channel_growth_sharing( + app: AppHandle, + input: UpdateChannelGrowthSharingInput, +) -> Result { + let database = channel_growth_database(&app)?; + tauri::async_runtime::spawn_blocking(move || update_sharing_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))? +} + +fn channel_growth_database(app: &AppHandle) -> Result { + let root = crate::authenticated_storage::account_storage_root(app, "channel-growth-v1")?; + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_PERMISSION_FAILED: {error}"))?; + } + Ok(root.join("channel-growth.sqlite3")) +} + +fn open_database(path: &Path) -> Result { + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?; + connection + .execute_batch( + "PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS growth_sharing ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + sharing_mode TEXT NOT NULL CHECK(sharing_mode IN ('LOCAL_ONLY', 'ANONYMIZED_SHARED')) + ); + INSERT OR IGNORE INTO growth_sharing VALUES (1, 'LOCAL_ONLY'); + CREATE TABLE IF NOT EXISTS growth_events ( + sequence INTEGER NOT NULL UNIQUE, + event_id TEXT PRIMARY KEY NOT NULL, + event_kind TEXT NOT NULL, + module_id TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('HUMAN_ACTION', 'PERSONA_COLLABORATION', 'SYSTEM_ASSISTED')), + occurred_at_unix_ms INTEGER NOT NULL, + previous_hash TEXT NOT NULL, + event_hash TEXT NOT NULL UNIQUE + ); + CREATE TRIGGER IF NOT EXISTS growth_events_no_update BEFORE UPDATE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END; + CREATE TRIGGER IF NOT EXISTS growth_events_no_delete BEFORE DELETE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END; + CREATE TABLE IF NOT EXISTS module_affinities ( + module_id TEXT PRIMARY KEY NOT NULL, + use_count INTEGER NOT NULL, + score REAL NOT NULL, + last_used_at_unix_ms INTEGER NOT NULL + );", + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SCHEMA_FAILED: {error}"))?; + Ok(connection) +} + +fn validate_token(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.len() > 96 + || !value + .chars() + .all(|item| item.is_ascii_alphanumeric() || matches!(item, '-' | '_' | '.' | ':')) + { + return Err(format!("HOLOLAKE_CHANNEL_GROWTH_{label}_INVALID")); + } + Ok(value.to_string()) +} + +fn record_event_at( + database: &Path, + input: RecordChannelGrowthEventInput, +) -> Result { + let event_kind = validate_token(&input.event_kind, "EVENT_KIND")?; + let module_id = validate_token(&input.module_id, "MODULE_ID")?; + let source = input.source.trim().to_string(); + if !matches!( + source.as_str(), + "HUMAN_ACTION" | "PERSONA_COLLABORATION" | "SYSTEM_ASSISTED" + ) { + return Err("HOLOLAKE_CHANNEL_GROWTH_SOURCE_INVALID".into()); + } + let mut connection = open_database(database)?; + verify_event_chain(&connection)?; + let now = now_unix_ms()?; + let weight = if source == "PERSONA_COLLABORATION" { + 1.4 + } else { + 1.0 + }; + let transaction = connection + .transaction() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_TRANSACTION_FAILED: {error}"))?; + let (sequence, previous_hash): (i64, String) = transaction + .query_row( + "SELECT COALESCE(MAX(sequence), 0) + 1, COALESCE((SELECT event_hash FROM growth_events ORDER BY sequence DESC LIMIT 1), ?1) FROM growth_events", + params![ZERO_HASH], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let event_id = Uuid::new_v4().to_string(); + let event_hash = event_hash( + sequence, + &event_id, + &event_kind, + &module_id, + &source, + now, + &previous_hash, + ); + transaction + .execute( + "INSERT INTO growth_events(sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![sequence, event_id, event_kind, module_id, source, now, previous_hash, event_hash], + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_EVENT_WRITE_FAILED: {error}"))?; + transaction + .execute( + "INSERT INTO module_affinities(module_id, use_count, score, last_used_at_unix_ms) VALUES (?1, 1, ?2, ?3) + ON CONFLICT(module_id) DO UPDATE SET use_count = use_count + 1, score = score * 0.985 + excluded.score, last_used_at_unix_ms = excluded.last_used_at_unix_ms", + params![module_id, weight, now], + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_MODEL_WRITE_FAILED: {error}"))?; + transaction + .commit() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_COMMIT_FAILED: {error}"))?; + snapshot_with_connection(&connection) +} + +fn update_sharing_at( + database: &Path, + input: UpdateChannelGrowthSharingInput, +) -> Result { + if !matches!( + input.sharing_mode.as_str(), + "LOCAL_ONLY" | "ANONYMIZED_SHARED" + ) { + return Err("HOLOLAKE_CHANNEL_GROWTH_SHARING_MODE_INVALID".into()); + } + let connection = open_database(database)?; + connection + .execute( + "UPDATE growth_sharing SET sharing_mode = ?1 WHERE singleton = 1", + params![input.sharing_mode], + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SHARING_WRITE_FAILED: {error}"))?; + snapshot_with_connection(&connection) +} + +fn snapshot_at(database: &Path) -> Result { + let connection = open_database(database)?; + snapshot_with_connection(&connection) +} + +fn snapshot_with_connection(connection: &Connection) -> Result { + let last_event_hash = verify_event_chain(connection)?; + let controls = connection + .query_row( + "SELECT sharing_mode FROM growth_sharing WHERE singleton = 1", + [], + |row| { + Ok(ChannelGrowthControls { + sharing_mode: row.get(0)?, + official_read_access: false, + history_mutation_allowed: false, + structural_change_requires_human_confirmation: true, + }) + }, + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let observed_event_count = connection + .query_row("SELECT COUNT(*) FROM growth_events", [], |row| row.get(0)) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let persona_collaboration_event_count = connection + .query_row( + "SELECT COUNT(*) FROM growth_events WHERE source = 'PERSONA_COLLABORATION'", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let mut statement = connection + .prepare("SELECT module_id, use_count, score, last_used_at_unix_ms FROM module_affinities ORDER BY score DESC, last_used_at_unix_ms DESC LIMIT 12") + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let affinities = statement + .query_map([], |row| { + Ok(ChannelModuleAffinity { + module_id: row.get(0)?, + use_count: row.get(1)?, + score: row.get(2)?, + last_used_at_unix_ms: row.get(3)?, + }) + }) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let recommended_module_order = affinities + .iter() + .map(|item| item.module_id.clone()) + .collect(); + Ok(ChannelGrowthSnapshot { + schema: GROWTH_SCHEMA, + state: "APPEND_ONLY_LOCAL_GROWTH_ACTIVE", + controls, + observed_event_count, + persona_collaboration_event_count, + module_affinities: affinities, + recommended_module_order, + integrity_state: "VERIFIED_APPEND_ONLY_CHAIN", + last_event_hash, + model_api_state: "NOT_CONFIGURED", + model_proposal_policy: "LOCAL_VALIDATE_THEN_HUMAN_CONFIRM_BEFORE_STRUCTURAL_CHANGE", + data_boundary: "METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT", + }) +} + +fn event_hash( + sequence: i64, + event_id: &str, + event_kind: &str, + module_id: &str, + source: &str, + occurred_at_unix_ms: i64, + previous_hash: &str, +) -> String { + let material = format!("{GROWTH_SCHEMA}|{sequence}|{event_id}|{event_kind}|{module_id}|{source}|{occurred_at_unix_ms}|{previous_hash}"); + hex(digest(&SHA256, material.as_bytes()).as_ref()) +} + +fn verify_event_chain(connection: &Connection) -> Result { + let mut statement = connection + .prepare("SELECT sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash FROM growth_events ORDER BY sequence") + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let mut rows = statement + .query([]) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let mut expected_sequence = 1_i64; + let mut previous = ZERO_HASH.to_string(); + while let Some(row) = rows + .next() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))? + { + let sequence: i64 = row + .get(0) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let event_id: String = row + .get(1) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let event_kind: String = row + .get(2) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let module_id: String = row + .get(3) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let source: String = row + .get(4) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let occurred_at: i64 = row + .get(5) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let stored_previous: String = row + .get(6) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let stored_hash: String = row + .get(7) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + if sequence != expected_sequence + || stored_previous != previous + || stored_hash + != event_hash( + sequence, + &event_id, + &event_kind, + &module_id, + &source, + occurred_at, + &stored_previous, + ) + { + return Err("HOLOLAKE_CHANNEL_GROWTH_HISTORY_INTEGRITY_FAILED".into()); + } + expected_sequence += 1; + previous = stored_hash; + } + Ok(previous) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn now_unix_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))? + .as_millis() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn local_growth_model_records_only_bounded_metadata_and_ranks_modules() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + for _ in 0..3 { + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "MODULE_OPEN".into(), + module_id: "education.table".into(), + source: "HUMAN_ACTION".into(), + }, + ) + .unwrap(); + } + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "PERSONA_COLLABORATION".into(), + module_id: "knowledge".into(), + source: "PERSONA_COLLABORATION".into(), + }, + ) + .unwrap(); + let snapshot = snapshot_at(&database).unwrap(); + assert_eq!(snapshot.observed_event_count, 4); + assert_eq!(snapshot.persona_collaboration_event_count, 1); + assert_eq!( + snapshot + .recommended_module_order + .first() + .map(String::as_str), + Some("education.table") + ); + assert_eq!( + snapshot.data_boundary, + "METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT" + ); + } + + #[test] + fn growth_history_rejects_update_and_delete() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "MODULE_OPEN".into(), + module_id: "knowledge".into(), + source: "HUMAN_ACTION".into(), + }, + ) + .unwrap(); + let connection = open_database(&database).unwrap(); + assert!(connection.execute("DELETE FROM growth_events", []).is_err()); + assert!(connection + .execute("UPDATE growth_events SET module_id = 'rewritten'", []) + .is_err()); + assert_eq!( + snapshot_at(&database).unwrap().integrity_state, + "VERIFIED_APPEND_ONLY_CHAIN" + ); + } + + #[test] + fn sharing_choice_does_not_grant_official_read_or_mutate_history() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + let snapshot = update_sharing_at( + &database, + UpdateChannelGrowthSharingInput { + sharing_mode: "ANONYMIZED_SHARED".into(), + }, + ) + .unwrap(); + assert_eq!(snapshot.controls.sharing_mode, "ANONYMIZED_SHARED"); + assert!(!snapshot.controls.official_read_access); + assert!(!snapshot.controls.history_mutation_allowed); + } +} 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..e8a2d73b4 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/education_translation.rs @@ -0,0 +1,1533 @@ +// 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; + +#[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, +} + +#[tauri::command] +pub async fn import_education_tables_from_dialog( + app: AppHandle, +) -> Result, String> { + 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: "IMPORTED_TO_NATIVE".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) +} + +#[tauri::command] +pub fn get_education_recognition_capability() -> EducationRecognitionCapability { + 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"], + } +} + +#[tauri::command] +pub async fn export_education_table_to_dialog( + app: AppHandle, + input: ExportEducationTableInput, +) -> Result, String> { + 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: "IMPORTED_TO_NATIVE", + 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" { + "NATIVE_TABLE_DATA".into() + } else { + "NONE".into() + }, + decision: if page.state == "READY_TO_TRANSLATE" { + "PROFILED_THEN_REGISTERED".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..bab370899 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/education_workspace.rs @@ -0,0 +1,1674 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 教育行业初始化频道的原生文档与表格数据层。 +//! +//! 公共主世界只投影模块状态;真实内容必须进入已登录账号的独立存储根。 +//! WebView 不直接持有文件路径或 SQLite 连接,所有写入都要求当前修订号,防止静默覆盖。 + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection, OptionalExtension}; +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; + +#[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, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EducationTableColumn { + pub column_id: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +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 document_count: usize, + pub table_count: usize, + pub automation_rule_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")] +pub struct CreateEducationItemInput { + pub title: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadEducationDocumentInput { + pub document_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +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")] +pub struct ReadEducationTableInput { + pub table_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +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")] +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")] +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")] +pub struct EducationAutomationRuleInput { + pub rule_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +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")] +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, +} + +#[tauri::command] +pub async fn get_education_workspace_snapshot( + app: AppHandle, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn create_education_document( + app: AppHandle, + input: CreateEducationItemInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn read_education_document( + app: AppHandle, + input: ReadEducationDocumentInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn save_education_document( + app: AppHandle, + input: SaveEducationDocumentInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn archive_education_document( + app: AppHandle, + input: ReadEducationDocumentInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn create_education_table( + app: AppHandle, + input: CreateEducationItemInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn read_education_table( + app: AppHandle, + input: ReadEducationTableInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn save_education_table( + app: AppHandle, + input: SaveEducationTableInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn archive_education_table( + app: AppHandle, + input: ReadEducationTableInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn create_education_automation_rule( + app: AppHandle, + input: CreateEducationAutomationRuleInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn save_education_automation_rule( + app: AppHandle, + input: SaveEducationAutomationRuleInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn archive_education_automation_rule( + app: AppHandle, + input: EducationAutomationRuleInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn preview_education_automation_rule( + app: AppHandle, + input: PreviewEducationAutomationRuleInput, +) -> Result { + 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}"))? +} + +#[tauri::command] +pub async fn execute_education_automation_rule( + app: AppHandle, + input: ExecuteEducationAutomationRuleInput, +) -> Result { + 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, + 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}"))?; + #[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 + 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 = + serde_json::from_str(&columns_json).unwrap_or_default(); + let rows: Vec = + serde_json::from_str(&rows_json).unwrap_or_default(); + 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)?; + 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, + storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE", + authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY", + }) +} + +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 = serde_json::from_str(&table_ids_json).unwrap_or_default(); + 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, archived + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 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, archived + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 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 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 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 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/knowledge_base.rs b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs index 9fec95dea..2533229bc 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs @@ -3,7 +3,7 @@ use ring::digest::{digest, SHA256}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsStr; use std::fs::{self, OpenOptions}; use std::io::Write; @@ -11,7 +11,7 @@ use std::io::Write; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::process::Command; -use std::time::UNIX_EPOCH; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Manager}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use uuid::Uuid; @@ -22,6 +22,11 @@ const MAX_IMPORT_FILES: usize = 1_000; const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; const MAX_READ_BYTES: u64 = 2 * 1024 * 1024; const MAX_SEARCH_RESULTS: usize = 100; +const ORGANIZATION_SCHEMA: &str = "hololake.knowledge-organization/v1"; +const MAX_CATEGORY_DEPTH: usize = 4; +const MAX_CATEGORY_SEGMENT_CHARS: usize = 40; +const MAX_TAGS_PER_DOCUMENT: usize = 12; +const MAX_TAG_CHARS: usize = 24; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -33,6 +38,24 @@ pub struct KnowledgeDocumentSummary { pub size_bytes: u64, pub content_sha256: String, pub duplicate_count: usize, + pub category: String, + pub category_explicit: bool, + pub tags: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeCategorySummary { + pub name: String, + pub document_count: usize, + pub explicitly_organized_count: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeTagSummary { + pub name: String, + pub document_count: usize, } #[derive(Clone, Debug, Serialize)] @@ -47,9 +70,54 @@ pub struct KnowledgeSnapshot { pub raw_document_count: usize, pub unique_document_count: usize, pub duplicate_document_count: usize, + pub organized_document_count: usize, + pub categories: Vec, + pub tags: Vec, + pub organization_revision: String, pub truncated: bool, } +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct KnowledgeOrganizationEntry { + category: String, + tags: Vec, + updated_at_unix_ms: u128, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct KnowledgeOrganizationIndex { + schema: String, + documents: BTreeMap, +} + +impl Default for KnowledgeOrganizationIndex { + fn default() -> Self { + Self { + schema: ORGANIZATION_SCHEMA.into(), + documents: BTreeMap::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpdateKnowledgeOrganizationInput { + pub source: String, + pub path: String, + pub expected_content_sha256: String, + pub category: String, + pub tags: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeOrganizationUpdateResult { + pub schema: &'static str, + pub state: &'static str, + pub git_commit: String, + pub snapshot: KnowledgeSnapshot, +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ReadKnowledgeDocumentInput { @@ -145,6 +213,13 @@ pub async fn get_knowledge_snapshot(app: AppHandle) -> Result Result { + let roots = knowledge_roots(app)?; + snapshot_at(&roots.0, roots.1.as_deref()) +} + #[tauri::command] pub async fn read_knowledge_document( app: AppHandle, @@ -180,6 +255,19 @@ pub async fn save_knowledge_document( .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } +#[tauri::command] +pub async fn update_knowledge_organization( + app: AppHandle, + input: UpdateKnowledgeOrganizationInput, +) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || { + update_organization_at(&roots.0, roots.1.as_deref(), input) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_JOIN_FAILED: {error}"))? +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExportKnowledgeDocumentInput { @@ -219,22 +307,50 @@ pub async fn export_knowledge_document( suggested = "知识页".to_string(); } // Windows 保留设备名(CON/NUL/COM1…)不能直接当文件名,撞名就补个尾巴。 - if matches!(suggested.to_ascii_uppercase().as_str(), - "CON" | "PRN" | "AUX" | "NUL" - | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" - | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") { + if matches!( + suggested.to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) { suggested.push_str("·页"); } suggested.push('.'); suggested.push_str(extension); - let filter_label = if extension == "html" { "网页文件" } else { "Markdown" }; + let filter_label = if extension == "html" { + "网页文件" + } else { + "Markdown" + }; let (sender, receiver) = std::sync::mpsc::channel::>(); app.dialog() .file() .set_file_name(&suggested) .add_filter(filter_label, &[extension]) .save_file(move |selection| { - let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf()); + let picked = selection + .as_ref() + .and_then(|path| path.as_path()) + .map(|path| path.to_path_buf()); let _ = sender.send(picked); }); let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten()) @@ -259,6 +375,7 @@ pub async fn export_knowledge_document( pub struct DeleteKnowledgeDocumentInput { pub source: String, pub path: String, + pub confirmed: bool, } #[derive(Clone, Debug, Serialize)] @@ -279,22 +396,6 @@ fn resolve_native_path(root: &Path, relative: &str) -> Result { Ok(target) } -async fn confirm_destructive(app: &AppHandle, message: String) -> Result { - let confirmer = app.clone(); - let confirmed = tauri::async_runtime::spawn_blocking(move || { - confirmer - .dialog() - .message(message) - .title("删除确认") - .kind(MessageDialogKind::Warning) - .buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string())) - .blocking_show() - }) - .await - .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?; - Ok(confirmed) -} - #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateKnowledgeDocumentInput { @@ -308,7 +409,11 @@ pub async fn create_knowledge_document( ) -> Result { // 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。 let roots = knowledge_roots(&app)?; - let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() }; + let base = if input.title.trim().is_empty() { + "未命名页面".to_string() + } else { + input.title.trim().to_string() + }; let docs = roots.0.join("docs"); let mut name = base.clone(); let mut counter = 2u32; @@ -325,14 +430,22 @@ pub async fn create_knowledge_document( .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; file.write_all(body.as_bytes()) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; - file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + file.sync_all() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?; - git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?; + git( + &roots.0, + &["commit", "-m", &format!("新建页面:{name}")], + "COMMIT", + )?; Ok::<(), String>(()) }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??; - Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 }) + Ok(KnowledgeDeleteReceipt { + removed: format!("{receipt_name}.md"), + pages: 1, + }) } #[tauri::command] @@ -345,26 +458,25 @@ pub async fn delete_knowledge_document( return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into()); } let roots = knowledge_roots(&app)?; - let target = resolve_native_path(&roots.0, &input.path)?; + let target = resolve_native_path(&roots.0.join("docs"), &input.path)?; if !target.is_file() { return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into()); } - let title = target - .file_stem() - .and_then(OsStr::to_str) - .unwrap_or("这一页") - .to_string(); - if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? { + if !input.confirmed { return Ok(None); } - move_to_trash(&roots.0, &target)?; - Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 })) + archive_native_entry(&roots.0, &target, &input.path, false)?; + Ok(Some(KnowledgeDeleteReceipt { + removed: input.path, + pages: 1, + })) } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeleteKnowledgeFolderInput { pub folder: String, + pub confirmed: bool, } #[tauri::command] @@ -374,15 +486,11 @@ pub async fn delete_knowledge_folder( ) -> Result, String> { // 删整个文件夹 = 删本机库里该相对目录及其全部页面。 let roots = knowledge_roots(&app)?; - let target = resolve_native_path(&roots.0, &input.folder)?; - if target == roots.0 || !target.is_dir() { + let docs_root = roots.0.join("docs"); + let target = resolve_native_path(&docs_root, &input.folder)?; + if target == docs_root || !target.is_dir() { return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into()); } - let name = target - .file_name() - .and_then(OsStr::to_str) - .unwrap_or(&input.folder) - .to_string(); let pages = fs::read_dir(&target) .map(|entries| { entries @@ -391,11 +499,14 @@ pub async fn delete_knowledge_folder( .count() }) .unwrap_or(0); - if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? { + if !input.confirmed { return Ok(None); } - move_to_trash(&roots.0, &target)?; - Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages })) + archive_native_entry(&roots.0, &target, &input.folder, true)?; + Ok(Some(KnowledgeDeleteReceipt { + removed: input.folder, + pages, + })) } #[tauri::command] @@ -404,7 +515,9 @@ pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> { let window = app .get_webview_window("main") .ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?; - window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}")) + window + .print() + .map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}")) } #[tauri::command] @@ -476,7 +589,9 @@ fn move_to_trash(root: &Path, target: &Path) -> Result { .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); - let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display())); + let destination = root + .join(".trash") + .join(format!("{}-{stamp}", relative.display())); if let Some(parent) = destination.parent() { fs::create_dir_all(parent) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; @@ -486,9 +601,67 @@ fn move_to_trash(root: &Path, target: &Path) -> Result { Ok(destination) } +/// 把 docs 下的页面或目录移到库内回收站,同时清理对应的整理索引并留下 Git 回执。 +/// 文件移动与索引写入之间任一步失败,都会尽力把原件放回原位。 +fn archive_native_entry( + root: &Path, + target: &Path, + relative_docs_path: &str, + directory: bool, +) -> Result { + let (mut organization, _) = load_organization_index(root)?; + let previous_organization = organization.clone(); + let prefix = format!("{}/", relative_docs_path.trim_end_matches('/')); + if directory { + organization + .documents + .retain(|path, _| path != relative_docs_path && !path.starts_with(&prefix)); + } else { + organization.documents.remove(relative_docs_path); + } + + let destination = move_to_trash(root, target)?; + if let Err(error) = write_organization_index(root, &organization) { + let _ = fs::rename(&destination, target); + let _ = write_organization_index(root, &previous_organization); + return Err(error); + } + + let docs_pathspec = format!("docs/{relative_docs_path}"); + let trash_pathspec = destination + .strip_prefix(root) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))? + .to_string_lossy() + .replace('\\', "/"); + git( + root, + &[ + "add", + "-A", + "--", + &docs_pathspec, + &trash_pathspec, + ".hololake/organization-v1.json", + ], + "ARCHIVE_ADD", + )?; + git( + root, + &[ + "commit", + "-m", + &format!("knowledge: archive {relative_docs_path}"), + ], + "ARCHIVE_COMMIT", + )?; + Ok(destination) +} + fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> { fs::create_dir_all(root.join("docs").join("导入")) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?; + fs::create_dir_all(root.join(".hololake")) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?; #[cfg(unix)] fs::set_permissions(root, fs::Permissions::from_mode(0o700)) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?; @@ -504,12 +677,251 @@ fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> { Ok(()) } +fn organization_index_path(root: &Path) -> PathBuf { + root.join(".hololake").join("organization-v1.json") +} + +fn load_organization_index(root: &Path) -> Result<(KnowledgeOrganizationIndex, String), String> { + let path = organization_index_path(root); + if !path.exists() { + let index = KnowledgeOrganizationIndex::default(); + let bytes = serde_json::to_vec(&index).map_err(|error| { + format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SERIALIZE_FAILED: {error}") + })?; + return Ok((index, sha256_hex(&bytes))); + } + let bytes = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_READ_FAILED: {error}"))?; + if bytes.len() > 2 * 1024 * 1024 { + return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_TOO_LARGE".into()); + } + let index = serde_json::from_slice::(&bytes) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_INVALID: {error}"))?; + if index.schema != ORGANIZATION_SCHEMA { + return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SCHEMA_UNSUPPORTED".into()); + } + Ok((index, sha256_hex(&bytes))) +} + +fn normalize_category(value: &str) -> Result { + let segments = value + .split(['/', '\\']) + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.is_empty() || segments.len() > MAX_CATEGORY_DEPTH { + return Err("HOLOLAKE_KNOWLEDGE_CATEGORY_INVALID".into()); + } + for segment in &segments { + let character_count = segment.chars().count(); + if character_count == 0 + || character_count > MAX_CATEGORY_SEGMENT_CHARS + || matches!(*segment, "." | "..") + || segment + .chars() + .any(|character| character.is_control() || ":*?\"<>|".contains(character)) + { + return Err("HOLOLAKE_KNOWLEDGE_CATEGORY_INVALID".into()); + } + } + Ok(segments.join(" / ")) +} + +fn normalize_tags(tags: Vec) -> Result, String> { + if tags.len() > MAX_TAGS_PER_DOCUMENT { + return Err("HOLOLAKE_KNOWLEDGE_TAGS_INVALID".into()); + } + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + for tag in tags { + let value = tag.trim().trim_start_matches('#').trim().to_string(); + if value.is_empty() { + continue; + } + if value.chars().count() > MAX_TAG_CHARS + || value.chars().any(|character| character.is_control()) + { + return Err("HOLOLAKE_KNOWLEDGE_TAGS_INVALID".into()); + } + let key = value.to_lowercase(); + if seen.insert(key) { + normalized.push(value); + } + } + normalized.sort(); + Ok(normalized) +} + +fn derived_category(path: &str) -> String { + let parts = path + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.first().copied() == Some("导入") { + return parts.get(2).copied().unwrap_or("根目录").to_string(); + } + if parts.len() > 1 { + parts[0].to_string() + } else { + "根目录".into() + } +} + +fn frontmatter_tags(bytes: &[u8]) -> Vec { + let Ok(body) = std::str::from_utf8(bytes) else { + return Vec::new(); + }; + let normalized = body.replace("\r\n", "\n"); + if !normalized.starts_with("---\n") { + return Vec::new(); + } + let Some(closing) = normalized[4..].find("\n---\n").map(|position| position + 4) else { + return Vec::new(); + }; + let mut tags = Vec::new(); + let mut reading_list = false; + for line in normalized[4..closing].lines() { + let trimmed = line.trim(); + if reading_list && trimmed.starts_with('-') { + tags.push( + trimmed + .trim_start_matches('-') + .trim() + .trim_matches(['\'', '"']) + .to_string(), + ); + continue; + } + reading_list = false; + let Some((key, value)) = trimmed.split_once(':') else { + continue; + }; + if key.trim() != "tags" { + continue; + } + let value = value.trim(); + if value.is_empty() { + reading_list = true; + continue; + } + let inline = value.trim_matches(['[', ']']); + tags.extend( + inline + .split(',') + .map(|tag| tag.trim().trim_matches(['\'', '"']).to_string()), + ); + } + normalize_tags(tags).unwrap_or_default() +} + +fn replace_organization_file(target: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = target + .parent() + .ok_or_else(|| "HOLOLAKE_KNOWLEDGE_ORGANIZATION_PATH_INVALID".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?; + let temporary = parent.join(format!("organization-{}.tmp", Uuid::new_v4())); + let mut file = private_file_options() + .open(&temporary) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?; + if !target.exists() { + return fs::rename(&temporary, target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}")); + } + let backup = parent.join(format!("organization-{}.backup", Uuid::new_v4())); + fs::rename(target, &backup) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?; + if let Err(error) = fs::rename(&temporary, target) { + let _ = fs::rename(&backup, target); + let _ = fs::remove_file(&temporary); + return Err(format!( + "HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}" + )); + } + let _ = fs::remove_file(backup); + Ok(()) +} + +fn write_organization_index(root: &Path, index: &KnowledgeOrganizationIndex) -> Result<(), String> { + let mut bytes = serde_json::to_vec_pretty(index) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SERIALIZE_FAILED: {error}"))?; + bytes.push(b'\n'); + replace_organization_file(&organization_index_path(root), &bytes) +} + +fn update_organization_at( + native: &Path, + legacy: Option<&Path>, + input: UpdateKnowledgeOrganizationInput, +) -> Result { + if input.source != "native" { + return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_READ_ONLY_SOURCE".into()); + } + let category = normalize_category(&input.category)?; + let tags = normalize_tags(input.tags)?; + let document_path = safe_document_path(&native.join("docs"), &input.path)?; + let document_bytes = fs::read(&document_path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if sha256_hex(&document_bytes) != input.expected_content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_CONFLICT".into()); + } + let (mut index, _) = load_organization_index(native)?; + let unchanged = index + .documents + .get(&input.path) + .map(|entry| entry.category == category && entry.tags == tags) + .unwrap_or(false); + if !unchanged { + let updated_at_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + index.documents.insert( + input.path.clone(), + KnowledgeOrganizationEntry { + category, + tags, + updated_at_unix_ms, + }, + ); + write_organization_index(native, &index)?; + git( + native, + &["add", "--", ".hololake/organization-v1.json"], + "ORGANIZATION_ADD", + )?; + git( + native, + &[ + "commit", + "-m", + &format!("knowledge: organize {}", input.path), + ], + "ORGANIZATION_COMMIT", + )?; + } + let git_commit = git(native, &["rev-parse", "HEAD"], "ORGANIZATION_READBACK")? + .trim() + .to_string(); + Ok(KnowledgeOrganizationUpdateResult { + schema: "hololake.knowledge-organization-update/v1", + state: if unchanged { "UNCHANGED" } else { "SAVED" }, + git_commit, + snapshot: snapshot_at(native, legacy)?, + }) +} + fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result { + let (organization, organization_revision) = load_organization_index(native)?; let mut raw_documents = Vec::new(); collect_documents( &native.join("docs"), &native.join("docs"), "native", + Some(&organization), &mut raw_documents, )?; if let Some(root) = legacy { @@ -517,6 +929,7 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result) -> Result= MAX_TREE_DOCUMENTS; documents.truncate(MAX_TREE_DOCUMENTS); let unique_document_count = documents.len(); + let organized_document_count = documents + .iter() + .filter(|document| document.category_explicit) + .count(); + let mut category_counts = BTreeMap::::new(); + let mut tag_counts = BTreeMap::::new(); + for document in &documents { + let category = category_counts + .entry(document.category.clone()) + .or_default(); + category.0 += 1; + if document.category_explicit { + category.1 += 1; + } + for tag in &document.tags { + *tag_counts.entry(tag.clone()).or_default() += 1; + } + } + let mut categories = category_counts + .into_iter() + .map( + |(name, (document_count, explicitly_organized_count))| KnowledgeCategorySummary { + name, + document_count, + explicitly_organized_count, + }, + ) + .collect::>(); + categories.sort_by(|left, right| { + right + .document_count + .cmp(&left.document_count) + .then_with(|| left.name.cmp(&right.name)) + }); + let mut tags = tag_counts + .into_iter() + .map(|(name, document_count)| KnowledgeTagSummary { + name, + document_count, + }) + .collect::>(); + tags.sort_by(|left, right| { + right + .document_count + .cmp(&left.document_count) + .then_with(|| left.name.cmp(&right.name)) + }); Ok(KnowledgeSnapshot { schema: SNAPSHOT_SCHEMA, state: "READY", @@ -549,6 +1009,10 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result, output: &mut Vec, ) -> Result<(), String> { if output.len() >= MAX_TREE_DOCUMENTS { @@ -581,7 +1046,7 @@ fn collect_documents( let name = entry.file_name(); if kind.is_dir() { if name != OsStr::new(".git") && name != OsStr::new(".hololake") { - collect_documents(root, &path, source, output)?; + collect_documents(root, &path, source, organization, output)?; } continue; } @@ -594,9 +1059,22 @@ fn collect_documents( if metadata.len() > MAX_READ_BYTES { continue; } - let relative = relative_posix(root, &path)?; let bytes = fs::read(&path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + let relative = relative_posix(root, &path)?; + let frontmatter_tags = frontmatter_tags(&bytes); + let indexed = organization.and_then(|index| index.documents.get(&relative)); + let category = indexed + .map(|entry| entry.category.clone()) + .filter(|category| !category.is_empty()) + .unwrap_or_else(|| derived_category(&relative)); + let mut tags = indexed.map(|entry| entry.tags.clone()).unwrap_or_default(); + for tag in frontmatter_tags { + if !tags.contains(&tag) { + tags.push(tag); + } + } + tags.sort(); output.push(KnowledgeDocumentSummary { source, path: relative, @@ -605,6 +1083,9 @@ fn collect_documents( size_bytes: metadata.len(), content_sha256: sha256_hex(&bytes), duplicate_count: 0, + category, + category_explicit: indexed.is_some(), + tags, }); } Ok(()) @@ -990,7 +1471,9 @@ fn strip_notion_links(line: &str) -> String { let text_end = i + 1 + close; if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' { if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') { - let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect(); + let href: String = bytes[text_end + 2..text_end + 2 + paren_end] + .iter() + .collect(); if href.contains("notion.so") || href.contains("notion.site") { out.extend(bytes[i + 1..text_end].iter()); i = text_end + 2 + paren_end + 1; @@ -1376,4 +1859,130 @@ mod tests { assert_eq!(results.len(), 1); assert_eq!(results[0].title, "星湖"); } + + #[test] + fn snapshot_builds_unified_categories_and_frontmatter_tags() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::create_dir_all(native.path().join("docs/导入/旧库/课程")).unwrap(); + fs::write( + native.path().join("docs/导入/旧库/课程/第一课.md"), + "---\ntags: [教学, 重点]\n---\n# 第一课\n", + ) + .unwrap(); + + let snapshot = snapshot_at(native.path(), None).unwrap(); + + assert_eq!(snapshot.documents[0].category, "课程"); + assert_eq!(snapshot.documents[0].tags, vec!["教学", "重点"]); + assert_eq!(snapshot.categories[0].name, "课程"); + assert_eq!(snapshot.tags.len(), 2); + assert_eq!(snapshot.organized_document_count, 0); + } + + #[test] + fn native_organization_is_revision_locked_git_backed_and_does_not_rewrite_body() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + let body = "---\ntags: [原有]\n---\n# 页面\n\n正文不应被整理操作改写。\n"; + fs::write(native.path().join("docs/page.md"), body).unwrap(); + git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap(); + git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap(); + let hash = sha256_hex(body.as_bytes()); + + let result = update_organization_at( + native.path(), + None, + UpdateKnowledgeOrganizationInput { + source: "native".into(), + path: "page.md".into(), + expected_content_sha256: hash.clone(), + category: "教学 / 课程".into(), + tags: vec!["待复盘".into(), "重点".into()], + }, + ) + .unwrap(); + + assert_eq!(result.state, "SAVED"); + assert_eq!(result.git_commit.len(), 40); + assert_eq!( + fs::read_to_string(native.path().join("docs/page.md")).unwrap(), + body + ); + let document = &result.snapshot.documents[0]; + assert_eq!(document.category, "教学 / 课程"); + assert!(document.category_explicit); + assert_eq!(document.tags, vec!["原有", "待复盘", "重点"]); + assert_eq!(result.snapshot.organized_document_count, 1); + + let stale = update_organization_at( + native.path(), + None, + UpdateKnowledgeOrganizationInput { + source: "native".into(), + path: "page.md".into(), + expected_content_sha256: sha256_hex(b"stale"), + category: "其他".into(), + tags: vec![], + }, + ) + .unwrap_err(); + assert!(stale.contains("ORGANIZATION_CONFLICT")); + } + + #[test] + fn organized_page_archive_moves_into_recovery_cleans_index_and_commits() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + let body = "# 待归档页面\n"; + let target = native.path().join("docs/page.md"); + fs::write(&target, body).unwrap(); + git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap(); + git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap(); + update_organization_at( + native.path(), + None, + UpdateKnowledgeOrganizationInput { + source: "native".into(), + path: "page.md".into(), + expected_content_sha256: sha256_hex(body.as_bytes()), + category: "验收 / 归档".into(), + tags: vec!["可恢复".into()], + }, + ) + .unwrap(); + + let destination = archive_native_entry(native.path(), &target, "page.md", false).unwrap(); + + assert!(!target.exists()); + assert!(destination.is_file()); + assert!(destination.starts_with(native.path().join(".trash"))); + let (organization, _) = load_organization_index(native.path()).unwrap(); + assert!(!organization.documents.contains_key("page.md")); + assert_eq!( + snapshot_at(native.path(), None) + .unwrap() + .unique_document_count, + 0 + ); + assert!( + git(native.path(), &["status", "--porcelain"], "TEST_STATUS") + .unwrap() + .trim() + .is_empty() + ); + } + + #[test] + fn organization_rejects_unbounded_or_cross_platform_invalid_labels() { + assert!(normalize_category("课程/章节/单元/课时/过深").is_err()); + assert!(normalize_category("课程:非法").is_err()); + assert!(normalize_tags(vec!["a".repeat(MAX_TAG_CHARS + 1)]).is_err()); + assert!(normalize_tags( + (0..=MAX_TAGS_PER_DOCUMENT) + .map(|index| index.to_string()) + .collect() + ) + .is_err()); + } } 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 e6a94d19c..1ff5cc290 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -1,10 +1,13 @@ mod authenticated_storage; +mod channel_growth; mod circular_lake_membrane; mod code_channel; 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; @@ -13,6 +16,8 @@ mod gls_protocol_runtime; mod home_status; mod knowledge_base; mod local_development_bridge; +mod native_composition; +mod persona_channel_body; mod persona_time_authority; mod personal_channel; mod pncc_receipt_projection; @@ -59,14 +64,42 @@ pub fn run() { personal_channel::initialize_personal_channel, personal_channel::create_personal_channel_task, personal_channel::transition_personal_channel_task, + channel_growth::get_channel_growth_snapshot, + channel_growth::record_channel_growth_event, + channel_growth::update_channel_growth_sharing, persona_time_authority::issue_persona_time_ticket, persona_time_authority::start_persona_time_authority, persona_time_authority::get_beijing_time_coordinate, persona_time_authority::get_guanghu_era_timeline, + persona_channel_body::get_persona_channel_body, + persona_channel_body::register_trial_persona, + persona_channel_body::delete_trial_persona, + persona_channel_body::accept_persona_language_contract, + persona_channel_body::append_persona_language, + native_composition::get_native_composition_module_registry, + native_composition::execute_knowledge_native_composition, + education_workspace::get_education_workspace_snapshot, + education_workspace::create_education_document, + education_workspace::read_education_document, + education_workspace::save_education_document, + education_workspace::archive_education_document, + education_workspace::create_education_table, + education_workspace::read_education_table, + education_workspace::save_education_table, + education_workspace::archive_education_table, + education_translation::import_education_tables_from_dialog, + education_translation::export_education_table_to_dialog, + education_translation::get_education_recognition_capability, + education_workspace::create_education_automation_rule, + education_workspace::save_education_automation_rule, + education_workspace::archive_education_automation_rule, + education_workspace::preview_education_automation_rule, + education_workspace::execute_education_automation_rule, knowledge_base::get_knowledge_snapshot, knowledge_base::read_knowledge_document, knowledge_base::search_knowledge, knowledge_base::save_knowledge_document, + knowledge_base::update_knowledge_organization, knowledge_base::select_and_import_knowledge_folder, knowledge_base::export_knowledge_document, knowledge_base::create_knowledge_document, diff --git a/product-source/hololake-native-desktop/src-tauri/src/native_composition.rs b/product-source/hololake-native-desktop/src-tauri/src/native_composition.rs new file mode 100644 index 000000000..e01b11765 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/native_composition.rs @@ -0,0 +1,833 @@ +//! HoloLake 原生组合执行内核。 +//! +//! 外来文件格式不进入这里。内核只接受登记过的模块、受限配方和当前登录账号的 +//! HoloLake 原生对象;人类投影只是同一执行结果的不同视图,没有源数据所有权。 + +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const REGISTRY_SCHEMA: &str = "hololake.composition-module-registry/v1"; +const OBJECT_SCHEMA: &str = "hololake.native-object/v1"; +const RECIPE_SCHEMA: &str = "hololake.composition-recipe/v1"; +const PROJECTION_SCHEMA: &str = "hololake.human-projection/v1"; +const MAX_ROWS: usize = 5_000; +const MAX_VIEWS: usize = 5; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CompositionDimension { + Source, + TopLevelFolder, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CompositionMeasure { + DocumentCount, + TotalBytes, + DuplicateCount, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ProjectionView { + Dashboard, + Comparison, + VerticalBar, + Classification, + Table, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExecuteCompositionInput { + pub dimension: CompositionDimension, + pub measure: CompositionMeasure, + pub views: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModuleDescriptor { + pub module_id: &'static str, + pub display_name: &'static str, + pub kind: &'static str, + pub input_schema: Option<&'static str>, + pub output_schema: &'static str, + pub deterministic: bool, + pub authority: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModuleRegistry { + pub schema: &'static str, + pub state: &'static str, + pub modules: Vec, + pub arbitrary_script_allowed: bool, + pub unregistered_module_allowed: bool, + pub direct_projection_write_allowed: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeColumn { + pub column_id: &'static str, + pub title: &'static str, + pub value_type: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeKnowledgeRow { + pub row_id: String, + pub source: String, + pub path: String, + pub title: String, + pub top_level_folder: String, + pub size_bytes: u64, + pub duplicate_count: usize, + pub updated_at_unix_ms: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeObject { + pub schema: &'static str, + pub object_id: String, + pub title: &'static str, + pub columns: Vec, + pub rows: Vec, + pub row_count: usize, + pub truncated: bool, + pub source_receipt: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompositionNode { + pub node_id: String, + pub module_id: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompositionEdge { + pub from: String, + pub to: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompositionRecipe { + pub schema: &'static str, + pub recipe_id: &'static str, + pub title: &'static str, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectionGroup { + pub key: String, + pub label: String, + pub document_count: usize, + pub total_bytes: u64, + pub duplicate_count: usize, + pub measure_value: u64, + pub share: f64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectionMetrics { + pub raw_document_count: usize, + pub unique_document_count: usize, + pub duplicate_document_count: usize, + pub total_bytes: u64, + pub group_count: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeCompositionProjection { + pub schema: &'static str, + pub state: &'static str, + pub execution_id: String, + pub executed_at_unix_ms: u64, + pub source_is_real_account_data: bool, + pub read_only: bool, + pub dimension: CompositionDimension, + pub measure: CompositionMeasure, + pub views: Vec, + pub native_object: NativeObject, + pub groups: Vec, + pub metrics: ProjectionMetrics, + pub recipe: CompositionRecipe, + pub data_sha256: String, + pub receipt_sha256: String, +} + +fn registry() -> ModuleRegistry { + let modules = vec![ + descriptor( + "HLC-SOURCE-KNOWLEDGE-CATALOG", + "知识目录数据源", + "SOURCE", + None, + OBJECT_SCHEMA, + ), + descriptor( + "HLC-CLASSIFY-SOURCE", + "按来源分类", + "TRANSFORM", + Some(OBJECT_SCHEMA), + "hololake.classified-object/v1", + ), + descriptor( + "HLC-CLASSIFY-TOP-FOLDER", + "按一级目录分类", + "TRANSFORM", + Some(OBJECT_SCHEMA), + "hololake.classified-object/v1", + ), + descriptor( + "HLC-AGGREGATE-DOCUMENT-COUNT", + "统计文档数", + "TRANSFORM", + Some("hololake.classified-object/v1"), + "hololake.composition-result/v1", + ), + descriptor( + "HLC-AGGREGATE-TOTAL-BYTES", + "统计数据量", + "TRANSFORM", + Some("hololake.classified-object/v1"), + "hololake.composition-result/v1", + ), + descriptor( + "HLC-AGGREGATE-DUPLICATE-COUNT", + "统计重复数", + "TRANSFORM", + Some("hololake.classified-object/v1"), + "hololake.composition-result/v1", + ), + descriptor( + "HLC-PROJECT-DASHBOARD", + "仪表盘投影", + "PROJECTION", + Some("hololake.composition-result/v1"), + PROJECTION_SCHEMA, + ), + descriptor( + "HLC-PROJECT-COMPARISON", + "对比投影", + "PROJECTION", + Some("hololake.composition-result/v1"), + PROJECTION_SCHEMA, + ), + descriptor( + "HLC-PROJECT-VERTICAL-BAR", + "柱状图投影", + "PROJECTION", + Some("hololake.composition-result/v1"), + PROJECTION_SCHEMA, + ), + descriptor( + "HLC-PROJECT-CLASSIFICATION", + "分类投影", + "PROJECTION", + Some("hololake.composition-result/v1"), + PROJECTION_SCHEMA, + ), + descriptor( + "HLC-PROJECT-TABLE", + "表格投影", + "PROJECTION", + Some("hololake.composition-result/v1"), + PROJECTION_SCHEMA, + ), + ]; + ModuleRegistry { + schema: REGISTRY_SCHEMA, + state: "READY", + modules, + arbitrary_script_allowed: false, + unregistered_module_allowed: false, + direct_projection_write_allowed: false, + } +} + +fn descriptor( + module_id: &'static str, + display_name: &'static str, + kind: &'static str, + input_schema: Option<&'static str>, + output_schema: &'static str, +) -> ModuleDescriptor { + ModuleDescriptor { + module_id, + display_name, + kind, + input_schema, + output_schema, + deterministic: true, + authority: "CURRENT_AUTHENTICATED_ACCOUNT_READ_ONLY", + } +} + +#[tauri::command] +pub fn get_native_composition_module_registry() -> ModuleRegistry { + registry() +} + +#[tauri::command] +pub async fn execute_knowledge_native_composition( + app: AppHandle, + input: ExecuteCompositionInput, +) -> Result { + validate_input(&input)?; + let snapshot = tauri::async_runtime::spawn_blocking(move || { + crate::knowledge_base::snapshot_for_native_composition(&app) + }) + .await + .map_err(|error| format!("HOLOLAKE_COMPOSITION_JOIN_FAILED: {error}"))??; + execute_snapshot(snapshot, input) +} + +fn validate_input(input: &ExecuteCompositionInput) -> Result<(), String> { + if input.views.is_empty() || input.views.len() > MAX_VIEWS { + return Err("HOLOLAKE_COMPOSITION_VIEW_COUNT_INVALID".into()); + } + let unique = input.views.iter().copied().collect::>(); + if unique.len() != input.views.len() { + return Err("HOLOLAKE_COMPOSITION_DUPLICATE_VIEW".into()); + } + Ok(()) +} + +fn execute_snapshot( + snapshot: crate::knowledge_base::KnowledgeSnapshot, + input: ExecuteCompositionInput, +) -> Result { + if snapshot.documents.len() > MAX_ROWS { + return Err("HOLOLAKE_COMPOSITION_ROW_LIMIT_EXCEEDED".into()); + } + let rows = snapshot + .documents + .iter() + .enumerate() + .map(|(index, document)| NativeKnowledgeRow { + row_id: format!("knowledge-row-{}", index + 1), + source: document.source.to_string(), + path: document.path.clone(), + title: document.title.clone(), + top_level_folder: document.category.clone(), + size_bytes: document.size_bytes, + duplicate_count: document.duplicate_count, + updated_at_unix_ms: document.updated_at_unix_ms.min(u64::MAX as u128) as u64, + }) + .collect::>(); + let data_sha256 = sha256_json(&rows)?; + let native_object = NativeObject { + schema: OBJECT_SCHEMA, + object_id: format!("knowledge-catalog-{}", &data_sha256[..16]), + title: "当前账号知识目录", + columns: native_columns(), + row_count: rows.len(), + rows, + truncated: snapshot.truncated, + source_receipt: "CURRENT_AUTHENTICATED_ACCOUNT_KNOWLEDGE_SNAPSHOT", + }; + let mut aggregates = BTreeMap::::new(); + for row in &native_object.rows { + let key = match input.dimension { + CompositionDimension::Source => source_label(&row.source), + CompositionDimension::TopLevelFolder => row.top_level_folder.clone(), + }; + let entry = aggregates.entry(key).or_default(); + entry.0 += 1; + entry.1 = entry.1.saturating_add(row.size_bytes); + entry.2 = entry.2.saturating_add(row.duplicate_count); + } + let total_measure = aggregates + .values() + .map(|value| measure_value(*value, input.measure)) + .sum::(); + let mut groups = aggregates + .into_iter() + .map(|(label, value)| ProjectionGroup { + key: stable_key(&label), + label, + document_count: value.0, + total_bytes: value.1, + duplicate_count: value.2, + measure_value: measure_value(value, input.measure), + share: if total_measure == 0 { + 0.0 + } else { + measure_value(value, input.measure) as f64 / total_measure as f64 + }, + }) + .collect::>(); + groups.sort_by(|left, right| { + right + .measure_value + .cmp(&left.measure_value) + .then_with(|| left.label.cmp(&right.label)) + }); + let recipe = compile_recipe(&input); + validate_recipe(&recipe, ®istry())?; + let metrics = ProjectionMetrics { + raw_document_count: snapshot.raw_document_count, + unique_document_count: snapshot.unique_document_count, + duplicate_document_count: snapshot.duplicate_document_count, + total_bytes: native_object.rows.iter().map(|row| row.size_bytes).sum(), + group_count: groups.len(), + }; + let executed_at_unix_ms = now_unix_ms(); + let receipt_material = serde_json::to_vec(&( + &data_sha256, + &recipe.nodes, + &recipe.edges, + &groups, + executed_at_unix_ms, + )) + .map_err(|error| format!("HOLOLAKE_COMPOSITION_RECEIPT_SERIALIZE_FAILED: {error}"))?; + Ok(NativeCompositionProjection { + schema: PROJECTION_SCHEMA, + state: "EXECUTED", + execution_id: format!("HLC-EXEC-{}", Uuid::new_v4()), + executed_at_unix_ms, + source_is_real_account_data: true, + read_only: true, + dimension: input.dimension, + measure: input.measure, + views: input.views, + native_object, + groups, + metrics, + recipe, + data_sha256, + receipt_sha256: sha256(&receipt_material), + }) +} + +fn native_columns() -> Vec { + vec![ + NativeColumn { + column_id: "title", + title: "标题", + value_type: "TEXT", + }, + NativeColumn { + column_id: "source", + title: "来源", + value_type: "TEXT", + }, + NativeColumn { + column_id: "topLevelFolder", + title: "一级分类", + value_type: "TEXT", + }, + NativeColumn { + column_id: "sizeBytes", + title: "数据量", + value_type: "INTEGER", + }, + NativeColumn { + column_id: "duplicateCount", + title: "重复数", + value_type: "INTEGER", + }, + NativeColumn { + column_id: "updatedAtUnixMs", + title: "更新时间", + value_type: "TIME", + }, + ] +} + +fn compile_recipe(input: &ExecuteCompositionInput) -> CompositionRecipe { + let classify = match input.dimension { + CompositionDimension::Source => "HLC-CLASSIFY-SOURCE", + CompositionDimension::TopLevelFolder => "HLC-CLASSIFY-TOP-FOLDER", + }; + let aggregate = match input.measure { + CompositionMeasure::DocumentCount => "HLC-AGGREGATE-DOCUMENT-COUNT", + CompositionMeasure::TotalBytes => "HLC-AGGREGATE-TOTAL-BYTES", + CompositionMeasure::DuplicateCount => "HLC-AGGREGATE-DUPLICATE-COUNT", + }; + let mut nodes = vec![ + CompositionNode { + node_id: "source".into(), + module_id: "HLC-SOURCE-KNOWLEDGE-CATALOG".into(), + }, + CompositionNode { + node_id: "classify".into(), + module_id: classify.into(), + }, + CompositionNode { + node_id: "aggregate".into(), + module_id: aggregate.into(), + }, + ]; + let mut edges = vec![ + CompositionEdge { + from: "source".into(), + to: "classify".into(), + }, + CompositionEdge { + from: "classify".into(), + to: "aggregate".into(), + }, + ]; + for (index, view) in input.views.iter().enumerate() { + let node_id = format!("projection-{}", index + 1); + nodes.push(CompositionNode { + node_id: node_id.clone(), + module_id: view_module_id(*view).into(), + }); + edges.push(CompositionEdge { + from: "aggregate".into(), + to: node_id, + }); + } + CompositionRecipe { + schema: RECIPE_SCHEMA, + recipe_id: "HLC-RECIPE-KNOWLEDGE-OVERVIEW-001", + title: "知识空间结构组合", + nodes, + edges, + } +} + +fn validate_recipe( + recipe: &CompositionRecipe, + module_registry: &ModuleRegistry, +) -> Result<(), String> { + let descriptors = module_registry + .modules + .iter() + .map(|item| (item.module_id, item)) + .collect::>(); + let nodes = recipe + .nodes + .iter() + .map(|item| (item.node_id.as_str(), item)) + .collect::>(); + if nodes.len() != recipe.nodes.len() { + return Err("HOLOLAKE_COMPOSITION_DUPLICATE_NODE".into()); + } + for node in &recipe.nodes { + if !descriptors.contains_key(node.module_id.as_str()) { + return Err("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED".into()); + } + } + let mut indegree = recipe + .nodes + .iter() + .map(|item| (item.node_id.as_str(), 0usize)) + .collect::>(); + let mut outgoing = BTreeMap::<&str, Vec<&str>>::new(); + for edge in &recipe.edges { + let from = nodes + .get(edge.from.as_str()) + .ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?; + let to = nodes + .get(edge.to.as_str()) + .ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?; + let from_descriptor = descriptors + .get(from.module_id.as_str()) + .ok_or("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED")?; + let to_descriptor = descriptors + .get(to.module_id.as_str()) + .ok_or("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED")?; + if to_descriptor.input_schema != Some(from_descriptor.output_schema) { + return Err("HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH".into()); + } + *indegree + .get_mut(to.node_id.as_str()) + .ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")? += 1; + outgoing + .entry(from.node_id.as_str()) + .or_default() + .push(to.node_id.as_str()); + } + let mut queue = indegree + .iter() + .filter_map(|(id, degree)| (*degree == 0).then_some(*id)) + .collect::>(); + let mut visited = 0usize; + while let Some(node) = queue.pop_front() { + visited += 1; + for target in outgoing.get(node).into_iter().flatten() { + let degree = indegree + .get_mut(target) + .ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?; + *degree -= 1; + if *degree == 0 { + queue.push_back(target); + } + } + } + if visited != recipe.nodes.len() { + return Err("HOLOLAKE_COMPOSITION_CYCLE_REJECTED".into()); + } + Ok(()) +} + +fn view_module_id(view: ProjectionView) -> &'static str { + match view { + ProjectionView::Dashboard => "HLC-PROJECT-DASHBOARD", + ProjectionView::Comparison => "HLC-PROJECT-COMPARISON", + ProjectionView::VerticalBar => "HLC-PROJECT-VERTICAL-BAR", + ProjectionView::Classification => "HLC-PROJECT-CLASSIFICATION", + ProjectionView::Table => "HLC-PROJECT-TABLE", + } +} + +fn measure_value(value: (usize, u64, usize), measure: CompositionMeasure) -> u64 { + match measure { + CompositionMeasure::DocumentCount => value.0 as u64, + CompositionMeasure::TotalBytes => value.1, + CompositionMeasure::DuplicateCount => value.2 as u64, + } +} + +#[cfg(test)] +fn top_level_folder(path: &str) -> String { + let normalized = path.replace('\\', "/"); + let parts = normalized + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + // “导入/导入批次名”是转译外壳,不是人类真正要比较的知识分类。 + let candidate = if parts.first() == Some(&"导入") { + parts.get(2).copied().unwrap_or("根页面") + } else { + parts.first().copied().unwrap_or("未分类") + }; + if candidate.ends_with(".md") || candidate.ends_with(".markdown") || candidate.ends_with(".txt") + { + "根目录".into() + } else { + candidate.into() + } +} + +fn source_label(source: &str) -> String { + match source { + "native" => "光湖原生知识".into(), + "legacy" => "待迁移知识".into(), + other => other.into(), + } +} + +fn stable_key(value: &str) -> String { + sha256(value.as_bytes())[..16].to_string() +} +fn sha256_json(value: &T) -> Result { + serde_json::to_vec(value) + .map(|bytes| sha256(&bytes)) + .map_err(|error| format!("HOLOLAKE_COMPOSITION_DATA_SERIALIZE_FAILED: {error}")) +} +fn sha256(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge_base::{KnowledgeDocumentSummary, KnowledgeSnapshot}; + + fn snapshot() -> KnowledgeSnapshot { + KnowledgeSnapshot { + schema: "hololake.native-knowledge-base/v1", + state: "READY", + native_root: "/private/account".into(), + legacy_available: false, + legacy_root: None, + documents: vec![ + KnowledgeDocumentSummary { + source: "native", + path: "课程/第一课.md".into(), + title: "第一课".into(), + updated_at_unix_ms: 1, + size_bytes: 100, + content_sha256: "a".into(), + duplicate_count: 1, + category: "课程".into(), + category_explicit: false, + tags: vec!["教学".into()], + }, + KnowledgeDocumentSummary { + source: "native", + path: "课程/第二课.md".into(), + title: "第二课".into(), + updated_at_unix_ms: 2, + size_bytes: 300, + content_sha256: "b".into(), + duplicate_count: 0, + category: "课程".into(), + category_explicit: true, + tags: vec!["教学".into()], + }, + KnowledgeDocumentSummary { + source: "native", + path: "记录.md".into(), + title: "记录".into(), + updated_at_unix_ms: 3, + size_bytes: 50, + content_sha256: "c".into(), + duplicate_count: 0, + category: "根目录".into(), + category_explicit: false, + tags: vec![], + }, + ], + raw_document_count: 4, + unique_document_count: 3, + duplicate_document_count: 1, + organized_document_count: 1, + categories: vec![], + tags: vec![], + organization_revision: "test".into(), + truncated: false, + } + } + + #[test] + fn real_native_rows_feed_all_projection_views_from_one_result() { + let result = execute_snapshot( + snapshot(), + ExecuteCompositionInput { + dimension: CompositionDimension::TopLevelFolder, + measure: CompositionMeasure::TotalBytes, + views: vec![ + ProjectionView::Dashboard, + ProjectionView::VerticalBar, + ProjectionView::Table, + ], + }, + ) + .unwrap(); + assert_eq!(result.state, "EXECUTED"); + assert_eq!(result.native_object.row_count, 3); + assert_eq!(result.metrics.total_bytes, 450); + assert_eq!(result.groups[0].label, "课程"); + assert_eq!(result.groups[0].measure_value, 400); + assert!(result.source_is_real_account_data); + assert!(result.read_only); + } + + #[test] + fn duplicate_or_empty_projection_requests_fail_closed() { + assert!(validate_input(&ExecuteCompositionInput { + dimension: CompositionDimension::Source, + measure: CompositionMeasure::DocumentCount, + views: vec![] + }) + .is_err()); + assert!(validate_input(&ExecuteCompositionInput { + dimension: CompositionDimension::Source, + measure: CompositionMeasure::DocumentCount, + views: vec![ProjectionView::Table, ProjectionView::Table] + }) + .is_err()); + } + + #[test] + fn imported_wrapper_folders_do_not_flatten_real_knowledge_categories() { + assert_eq!( + top_level_folder("导入/光湖语言世界/AI技能包/索引.md"), + "AI技能包" + ); + assert_eq!(top_level_folder("导入/光湖语言世界/首页.md"), "根目录"); + assert_eq!(top_level_folder("课程/第一课.md"), "课程"); + } + + #[test] + fn unknown_modules_type_mismatches_and_cycles_fail_closed() { + let mut unknown = compile_recipe(&ExecuteCompositionInput { + dimension: CompositionDimension::Source, + measure: CompositionMeasure::DocumentCount, + views: vec![ProjectionView::Table], + }); + unknown.nodes[0].module_id = "UNKNOWN".into(); + assert_eq!( + validate_recipe(&unknown, ®istry()).unwrap_err(), + "HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED" + ); + + let mut mismatch = compile_recipe(&ExecuteCompositionInput { + dimension: CompositionDimension::Source, + measure: CompositionMeasure::DocumentCount, + views: vec![ProjectionView::Table], + }); + mismatch.edges[0] = CompositionEdge { + from: "aggregate".into(), + to: "classify".into(), + }; + assert_eq!( + validate_recipe(&mismatch, ®istry()).unwrap_err(), + "HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH" + ); + + let cycle_registry = ModuleRegistry { + schema: REGISTRY_SCHEMA, + state: "TEST", + modules: vec![ + descriptor("LOOP-A", "A", "TRANSFORM", Some("loop/v1"), "loop/v1"), + descriptor("LOOP-B", "B", "TRANSFORM", Some("loop/v1"), "loop/v1"), + ], + arbitrary_script_allowed: false, + unregistered_module_allowed: false, + direct_projection_write_allowed: false, + }; + let cycle = CompositionRecipe { + schema: RECIPE_SCHEMA, + recipe_id: "TEST-CYCLE", + title: "cycle", + nodes: vec![ + CompositionNode { + node_id: "a".into(), + module_id: "LOOP-A".into(), + }, + CompositionNode { + node_id: "b".into(), + module_id: "LOOP-B".into(), + }, + ], + edges: vec![ + CompositionEdge { + from: "a".into(), + to: "b".into(), + }, + CompositionEdge { + from: "b".into(), + to: "a".into(), + }, + ], + }; + assert_eq!( + validate_recipe(&cycle, &cycle_registry).unwrap_err(), + "HOLOLAKE_COMPOSITION_CYCLE_REJECTED" + ); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs b/product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs new file mode 100644 index 000000000..417c0a4d8 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs @@ -0,0 +1,706 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 用户频道本体:多个人格体、三十天可逆试用期、语言合约与不可篡改语言时间链。 + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const BODY_SCHEMA: &str = "hololake.persona-channel-body/v1"; +const TRIAL_DURATION_MS: i64 = 30 * 24 * 60 * 60 * 1_000; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +const MAX_LANGUAGE_BYTES: usize = 2_000_000; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterTrialPersonaInput { + pub display_name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteTrialPersonaInput { + pub persona_id: String, + pub exact_confirmation: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcceptLanguageContractInput { + pub contract_version: String, + pub contract_text_sha256: String, + pub promote_trial_history: bool, + pub activate_immediately: bool, + pub exact_acceptance: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppendPersonaLanguageInput { + pub persona_id: String, + pub speaker: String, + pub language: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaBodySummary { + pub persona_id: String, + pub display_name: String, + pub state: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaChannelBodySnapshot { + pub schema: &'static str, + pub state: String, + pub trial_started_at_unix_ms: i64, + pub trial_ends_at_unix_ms: i64, + pub language_contract_accepted: bool, + pub personas: Vec, + pub trial_language_count: i64, + pub immutable_language_count: i64, + pub last_immutable_hash: String, + pub integrity_state: &'static str, + pub official_read_access: bool, + pub history_mutation_allowed: bool, +} + +#[tauri::command] +pub async fn get_persona_channel_body( + app: AppHandle, +) -> Result { + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database, now_unix_ms()?)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn register_trial_persona( + app: AppHandle, + input: RegisterTrialPersonaInput, +) -> Result { + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + register_trial_persona_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn delete_trial_persona( + app: AppHandle, + input: DeleteTrialPersonaInput, +) -> Result { + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + delete_trial_persona_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn accept_persona_language_contract( + app: AppHandle, + input: AcceptLanguageContractInput, +) -> Result { + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + accept_contract_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn append_persona_language( + app: AppHandle, + input: AppendPersonaLanguageInput, +) -> Result { + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + append_language_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +fn body_database(app: &AppHandle) -> Result { + let root = crate::authenticated_storage::account_storage_root(app, "persona-channel-body-v1")?; + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_PERMISSION_FAILED: {error}"))?; + } + Ok(root.join("persona-channel-body.sqlite3")) +} + +fn open_database(path: &Path, now: i64) -> Result { + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_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 lifecycle(singleton INTEGER PRIMARY KEY CHECK(singleton=1), state TEXT NOT NULL, trial_started_at_unix_ms INTEGER NOT NULL, trial_ends_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS language_contract(singleton INTEGER PRIMARY KEY CHECK(singleton=1), contract_version TEXT NOT NULL, contract_text_sha256 TEXT NOT NULL, accepted_at_unix_ms INTEGER NOT NULL, promote_trial_history INTEGER NOT NULL, acceptance_receipt_sha256 TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS personas(persona_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('REVERSIBLE_TRIAL','IMMUTABLE_ACTIVE')), created_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS trial_language(event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL REFERENCES personas(persona_id) ON DELETE CASCADE, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS immutable_language(sequence INTEGER NOT NULL UNIQUE, event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL, previous_hash TEXT NOT NULL, event_hash TEXT NOT NULL UNIQUE); + CREATE TRIGGER IF NOT EXISTS immutable_language_no_update BEFORE UPDATE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END; + CREATE TRIGGER IF NOT EXISTS immutable_language_no_delete BEFORE DELETE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END; + CREATE TRIGGER IF NOT EXISTS active_persona_no_delete BEFORE DELETE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_PERSISTENT'); END; + CREATE TRIGGER IF NOT EXISTS active_persona_no_update BEFORE UPDATE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_IMMUTABLE'); END; + CREATE TRIGGER IF NOT EXISTS accepted_contract_no_delete BEFORE DELETE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_PERSISTENT'); END; + CREATE TRIGGER IF NOT EXISTS accepted_contract_no_update BEFORE UPDATE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_IMMUTABLE'); END;" + ).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_SCHEMA_FAILED: {error}"))?; + connection + .execute( + "INSERT OR IGNORE INTO lifecycle VALUES (1,'REVERSIBLE_TRIAL',?1,?2)", + params![now, now + TRIAL_DURATION_MS], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?; + Ok(connection) +} + +fn refresh_lifecycle(connection: &mut Connection, now: i64) -> Result<(), String> { + let (state, ends): (String, i64) = connection + .query_row( + "SELECT state, trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if state == "REVERSIBLE_TRIAL" && now >= ends { + let accepted: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM language_contract)", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if accepted { + activate_real_trajectory(connection, now)?; + } else { + connection + .execute( + "UPDATE lifecycle SET state='CONTRACT_REQUIRED_CHANNEL_STOPPED' WHERE singleton=1", + [], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?; + } + } + Ok(()) +} + +fn register_trial_persona_at( + database: &Path, + input: RegisterTrialPersonaInput, + now: i64, +) -> Result { + let display_name = input.display_name.trim(); + if display_name.is_empty() || display_name.len() > 240 { + return Err("HOLOLAKE_PERSONA_DISPLAY_NAME_INVALID".into()); + } + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + let state: String = connection + .query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if state != "REVERSIBLE_TRIAL" { + return Err("HOLOLAKE_PERSONA_TRIAL_REGISTRATION_CLOSED".into()); + } + connection + .execute( + "INSERT INTO personas VALUES (?1,?2,'REVERSIBLE_TRIAL',?3)", + params![format!("persona-{}", Uuid::new_v4()), display_name, now], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_REGISTER_FAILED: {error}"))?; + snapshot_with_connection(&mut connection, now) +} + +fn delete_trial_persona_at( + database: &Path, + input: DeleteTrialPersonaInput, + now: i64, +) -> Result { + if input.exact_confirmation != format!("删除试用人格体 {}", input.persona_id) { + return Err("HOLOLAKE_TRIAL_PERSONA_EXACT_CONFIRMATION_REQUIRED".into()); + } + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + let changed = connection + .execute( + "DELETE FROM personas WHERE persona_id=?1 AND state='REVERSIBLE_TRIAL'", + params![input.persona_id], + ) + .map_err(|error| format!("HOLOLAKE_TRIAL_PERSONA_DELETE_FAILED: {error}"))?; + if changed != 1 { + return Err("HOLOLAKE_TRIAL_PERSONA_NOT_DELETABLE".into()); + } + snapshot_with_connection(&mut connection, now) +} + +fn accept_contract_at( + database: &Path, + input: AcceptLanguageContractInput, + now: i64, +) -> Result { + if input.exact_acceptance + != "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。" + { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_EXACT_ACCEPTANCE_REQUIRED".into()); + } + if input.contract_version.trim().is_empty() + || input.contract_version.len() > 64 + || input.contract_text_sha256.len() != 64 + || !input + .contract_text_sha256 + .chars() + .all(|item| item.is_ascii_hexdigit()) + { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_INVALID".into()); + } + let mut connection = open_database(database, now)?; + let receipt = sha256_hex( + format!( + "{}|{}|{}|{}", + input.contract_version, input.contract_text_sha256, input.promote_trial_history, now + ) + .as_bytes(), + ); + connection + .execute( + "INSERT INTO language_contract VALUES (1,?1,?2,?3,?4,?5)", + params![ + input.contract_version, + input.contract_text_sha256.to_ascii_lowercase(), + now, + input.promote_trial_history, + receipt + ], + ) + .map_err(|error| { + format!("HOLOLAKE_LANGUAGE_CONTRACT_ALREADY_ACCEPTED_OR_INVALID: {error}") + })?; + if input.activate_immediately { + activate_real_trajectory(&mut connection, now)?; + } else { + refresh_lifecycle(&mut connection, now)?; + } + snapshot_with_connection(&mut connection, now) +} + +fn append_language_at( + database: &Path, + input: AppendPersonaLanguageInput, + now: i64, +) -> Result { + if input.language.trim().is_empty() || input.language.len() > MAX_LANGUAGE_BYTES { + return Err("HOLOLAKE_PERSONA_LANGUAGE_INVALID".into()); + } + if !matches!( + input.speaker.as_str(), + "HUMAN" | "PERSONA" | "SYSTEM_RECEIPT" + ) { + return Err("HOLOLAKE_PERSONA_LANGUAGE_SPEAKER_INVALID".into()); + } + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + let lifecycle: String = connection + .query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let persona_state: Option = connection + .query_row( + "SELECT state FROM personas WHERE persona_id=?1", + params![input.persona_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if persona_state.is_none() { + return Err("HOLOLAKE_PERSONA_NOT_FOUND".into()); + } + if lifecycle == "REVERSIBLE_TRIAL" { + connection + .execute( + "INSERT INTO trial_language VALUES (?1,?2,?3,?4,?5)", + params![ + Uuid::new_v4().to_string(), + input.persona_id, + input.speaker, + input.language, + now + ], + ) + .map_err(|error| format!("HOLOLAKE_TRIAL_LANGUAGE_WRITE_FAILED: {error}"))?; + } else if lifecycle == "IMMUTABLE_ACTIVE" { + append_immutable( + &mut connection, + &input.persona_id, + &input.speaker, + &input.language, + now, + )?; + } else { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_REQUIRED_BEFORE_MORE_PERSONA_GROWTH".into()); + } + snapshot_with_connection(&mut connection, now) +} + +fn activate_real_trajectory(connection: &mut Connection, now: i64) -> Result<(), String> { + let promote: bool = connection + .query_row( + "SELECT promote_trial_history FROM language_contract", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let transaction = connection + .transaction() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_TRANSACTION_FAILED: {error}"))?; + if promote { + let trial = { + let mut statement = transaction.prepare("SELECT event_id,persona_id,speaker,language,occurred_at_unix_ms FROM trial_language ORDER BY occurred_at_unix_ms,event_id").map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + )) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + rows + }; + let mut previous = ZERO_HASH.to_string(); + for (index, (event_id, persona_id, speaker, language, occurred_at)) in + trial.into_iter().enumerate() + { + let sequence = index as i64 + 1; + let hash = language_hash( + sequence, + &event_id, + &persona_id, + &speaker, + &language, + occurred_at, + &previous, + ); + transaction + .execute( + "INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + sequence, + event_id, + persona_id, + speaker, + language, + occurred_at, + previous, + hash + ], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_SEAL_FAILED: {error}"))?; + previous = hash; + } + } + transaction + .execute("DELETE FROM trial_language", []) + .map_err(|error| format!("HOLOLAKE_TRIAL_PROMOTION_FAILED: {error}"))?; + transaction + .execute( + "UPDATE personas SET state='IMMUTABLE_ACTIVE' WHERE state='REVERSIBLE_TRIAL'", + [], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?; + transaction + .execute( + "UPDATE lifecycle SET state='IMMUTABLE_ACTIVE' WHERE singleton=1", + [], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?; + transaction + .commit() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_COMMIT_FAILED: {error}"))?; + let _ = now; + Ok(()) +} + +fn append_immutable( + connection: &mut Connection, + persona_id: &str, + speaker: &str, + language: &str, + occurred_at: i64, +) -> Result<(), String> { + verify_immutable_chain(connection)?; + let (sequence, previous): (i64,String) = connection.query_row("SELECT COALESCE(MAX(sequence),0)+1,COALESCE((SELECT event_hash FROM immutable_language ORDER BY sequence DESC LIMIT 1),?1) FROM immutable_language", params![ZERO_HASH], |row| Ok((row.get(0)?,row.get(1)?))).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let event_id = Uuid::new_v4().to_string(); + let hash = language_hash( + sequence, + &event_id, + persona_id, + speaker, + language, + occurred_at, + &previous, + ); + connection + .execute( + "INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + sequence, + event_id, + persona_id, + speaker, + language, + occurred_at, + previous, + hash + ], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_APPEND_FAILED: {error}"))?; + Ok(()) +} + +fn snapshot_at(database: &Path, now: i64) -> Result { + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + snapshot_with_connection(&mut connection, now) +} +fn snapshot_with_connection( + connection: &mut Connection, + _now: i64, +) -> Result { + let (state,started,ends):(String,i64,i64)=connection.query_row("SELECT state,trial_started_at_unix_ms,trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?))).map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let accepted = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM language_contract)", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let personas = { + let mut statement=connection.prepare("SELECT persona_id,display_name,state,created_at_unix_ms FROM personas ORDER BY created_at_unix_ms").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok(PersonaBodySummary { + persona_id: row.get(0)?, + display_name: row.get(1)?, + state: row.get(2)?, + created_at_unix_ms: row.get(3)?, + }) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + rows + }; + let trial_language_count = connection + .query_row("SELECT COUNT(*) FROM trial_language", [], |row| row.get(0)) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let immutable_language_count = connection + .query_row("SELECT COUNT(*) FROM immutable_language", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let last = verify_immutable_chain(connection)?; + Ok(PersonaChannelBodySnapshot { + schema: BODY_SCHEMA, + state, + trial_started_at_unix_ms: started, + trial_ends_at_unix_ms: ends, + language_contract_accepted: accepted, + personas, + trial_language_count, + immutable_language_count, + last_immutable_hash: last, + integrity_state: "VERIFIED", + official_read_access: false, + history_mutation_allowed: false, + }) +} + +fn language_hash( + sequence: i64, + event_id: &str, + persona_id: &str, + speaker: &str, + language: &str, + occurred_at: i64, + previous: &str, +) -> String { + sha256_hex(format!("{BODY_SCHEMA}|{sequence}|{event_id}|{persona_id}|{speaker}|{}|{occurred_at}|{previous}",sha256_hex(language.as_bytes())).as_bytes()) +} +fn verify_immutable_chain(connection: &Connection) -> Result { + let mut statement=connection.prepare("SELECT sequence,event_id,persona_id,speaker,language,occurred_at_unix_ms,previous_hash,event_hash FROM immutable_language ORDER BY sequence").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let mut rows = statement + .query([]) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let mut sequence = 1; + let mut previous = ZERO_HASH.to_string(); + while let Some(row) = rows + .next() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))? + { + let event_id: String = row.get(1).unwrap(); + let persona_id: String = row.get(2).unwrap(); + let speaker: String = row.get(3).unwrap(); + let language: String = row.get(4).unwrap(); + let occurred: i64 = row.get(5).unwrap(); + let stored_previous: String = row.get(6).unwrap(); + let stored_hash: String = row.get(7).unwrap(); + if row.get::<_, i64>(0).unwrap() != sequence + || stored_previous != previous + || stored_hash + != language_hash( + sequence, + &event_id, + &persona_id, + &speaker, + &language, + occurred, + &stored_previous, + ) + { + return Err("HOLOLAKE_PERSONA_LANGUAGE_INTEGRITY_FAILED".into()); + } + previous = stored_hash; + sequence += 1; + } + Ok(previous) +} +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} +fn now_unix_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))? + .as_millis() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + #[test] + fn trial_is_reversible_but_unsigned_expiry_does_not_silently_become_permanent() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 1_000_000; + let persona = register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "试用人格".into(), + }, + start, + ) + .unwrap() + .personas[0] + .persona_id + .clone(); + append_language_at( + &database, + AppendPersonaLanguageInput { + persona_id: persona.clone(), + speaker: "HUMAN".into(), + language: "第一句话".into(), + }, + start + 1, + ) + .unwrap(); + let pending = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap(); + assert_eq!(pending.state, "CONTRACT_REQUIRED_CHANNEL_STOPPED"); + assert!(append_language_at( + &database, + AppendPersonaLanguageInput { + persona_id: persona, + speaker: "HUMAN".into(), + language: "未签约".into() + }, + start + TRIAL_DURATION_MS + 1 + ) + .is_err()); + } + #[test] + fn signed_trial_promotes_to_an_immutable_millisecond_language_chain() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 2_000_000; + let persona = register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "长期人格".into(), + }, + start, + ) + .unwrap() + .personas[0] + .persona_id + .clone(); + append_language_at( + &database, + AppendPersonaLanguageInput { + persona_id: persona.clone(), + speaker: "HUMAN".into(), + language: "真实语言".into(), + }, + start + 1, + ) + .unwrap(); + accept_contract_at(&database,AcceptLanguageContractInput{contract_version:"v1".into(),contract_text_sha256:"a".repeat(64),promote_trial_history:true,activate_immediately:false,exact_acceptance:"我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into()},start+2).unwrap(); + let active = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap(); + assert_eq!(active.state, "IMMUTABLE_ACTIVE"); + assert_eq!(active.immutable_language_count, 1); + let connection = open_database(&database, start + TRIAL_DURATION_MS).unwrap(); + assert!(connection + .execute("DELETE FROM immutable_language", []) + .is_err()); + assert!(connection.execute("DELETE FROM personas", []).is_err()); + } + + #[test] + fn a_human_may_sign_early_and_enter_the_real_trajectory_immediately() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 3_000_000; + register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "提前签约人格".into(), + }, + start, + ) + .unwrap(); + let active = accept_contract_at(&database, AcceptLanguageContractInput { contract_version: "v1".into(), contract_text_sha256: "b".repeat(64), promote_trial_history: true, activate_immediately: true, exact_acceptance: "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into() }, start + 1).unwrap(); + assert_eq!(active.state, "IMMUTABLE_ACTIVE"); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs index c4fc1f877..6c8967425 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs @@ -90,7 +90,8 @@ pub async fn query_pncc_receipt_projection( } pub(crate) fn pncc_projection_root(app: &AppHandle) -> Result { - let root = crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?; + let root = + crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs index 6162d71fc..99f448741 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs @@ -166,7 +166,8 @@ pub async fn confirm_pncc_repository_mount( } pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result { - let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?; + let root = + crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() @@ -174,7 +175,8 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result Result { - let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?; + let root = + crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 0b51be164..4dd36c62e 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -1,9 +1,16 @@ -import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { StrictMode, Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import { invoke } from '@tauri-apps/api/core' +import { getCoreRowModel, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnDef, type SortingState } from '@tanstack/react-table' import { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, markdownHtml } from './modules/knowledge-render' +import { analyzeEducationTableData } from './modules/education-data' +import { buildEducationBaselineRenderPlan } from './modules/education-adaptive-rendering' +import { NativeCompositionStudio, type CompositionDimension, type CompositionMeasure, type NativeCompositionProjection, type ProjectionView } from './modules/native-composition' +import subdomainIndustryRegistry from '../contracts/subdomain-industry-routing.json' const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] +const EducationDocumentEngine = lazy(() => import('./modules/education-document-engine')) +const ChannelSpreadsheetEngine = lazy(() => import('./modules/channel-workbench/spreadsheet-engine')) // 编号自动补齐:人只敲字母与数字,杠由系统按已登记文法自动出。 // ICE 系(ICE-GL∞ / ICE-P-ZY001)、GLS 系(GLS-LA-20260720-003);认不出的文法只在字母数字交界处补杠。 @@ -49,7 +56,7 @@ import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' -type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'personalNodeGuide' | 'tool' +type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'subdomainIndustries' | 'educationChannel' | 'educationDocuments' | 'educationTables' | 'educationData' | 'educationAutomation' | 'educationComposition' | 'personalNodeGuide' | 'tool' type KnowledgeSource = 'native' | 'legacy' interface HomeStatus { @@ -168,7 +175,12 @@ interface KnowledgeDocumentSummary { sizeBytes: number contentSha256: string duplicateCount: number + category: string + categoryExplicit: boolean + tags: string[] } +interface KnowledgeCategorySummary { name: string; documentCount: number; explicitlyOrganizedCount: number } +interface KnowledgeTagSummary { name: string; documentCount: number } interface KnowledgeSnapshot { state: string nativeRoot: string @@ -178,6 +190,10 @@ interface KnowledgeSnapshot { rawDocumentCount: number uniqueDocumentCount: number duplicateDocumentCount: number + organizedDocumentCount: number + categories: KnowledgeCategorySummary[] + tags: KnowledgeTagSummary[] + organizationRevision: string truncated: boolean } interface KnowledgeDocument { @@ -203,6 +219,126 @@ interface KnowledgeImportResult { snapshot: KnowledgeSnapshot } interface KnowledgeSaveResult { state: string; gitCommit: string; document: KnowledgeDocument } +interface EducationDocumentSummary { documentId: string; title: string; revision: number; updatedAtUnixMs: number } +interface EducationDocument { documentId: string; title: string; body: string; revision: number; createdAtUnixMs: number; updatedAtUnixMs: number } +interface EducationTableSummary { tableId: string; title: string; revision: number; columnCount: number; rowCount: number; updatedAtUnixMs: number } +interface EducationTableColumn { columnId: string; title: string } +interface EducationTableRow { rowId: string; cells: string[] } +interface EducationTable { tableId: string; title: string; columns: EducationTableColumn[]; rows: EducationTableRow[]; revision: number; createdAtUnixMs: number; updatedAtUnixMs: number } +interface EducationContentPageProfile { pageName: string; state: string; usedRows: number; usedColumns: number; headerRowIndex?: number; headerConfidence: string; dataRows: number; textCells: number; numericCells: number; booleanCells: number; dateCells: number; formulaCells: number; structureKind: string; focusState: string; focusTitle: string; primaryMeasureColumn?: string; secondaryMeasureColumns: string[]; focusConfidence: string; focusReasons: string[] } +interface EducationContentRoute { pageName: string; sourceStructure: string; nativeKind: string; targetModule: string; decision: string } +interface EducationContentProfile { state: string; detectedFamily: string; recognitionMode: string; containerFormat: string; extensionMatchesContainer: boolean; pageKind: string; pageCount: number; nonEmptyPageCount: number; totalDataRows: number; totalColumns: number; pages: EducationContentPageProfile[]; routing: EducationContentRoute[]; unresolvedSignals: string[] } +interface EducationTableImportItem { tableId: string; title: string; sheetName: string; columnCount: number; rowCount: number; leadingRowsIgnored: number } +interface EducationTableImportReceipt { state: string; importId: string; sourceFormat: string; sourceFilename: string; sourceSha256: string; sourceBytes: number; nativeSchema: string; contentProfile: EducationContentProfile; importedTables: EducationTableImportItem[]; importedAtUnixMs: number; sourcePreservedReadOnly: boolean; truncated: boolean } +interface EducationRecognitionAssistanceReceipt { state: string; requestId: string; title: string; message: string; sourceFilename: string; detectedContainer: string; reasonCode: string; modelAssistanceEligible: boolean; modelApiState: string; modelApiSlot: string; requiresExplicitFileConsent: boolean; sourcePreservedReadOnly: boolean; availableLearningScopes: string[] } +interface EducationImportOutcome { state: string; importReceipt?: EducationTableImportReceipt; assistanceReceipt?: EducationRecognitionAssistanceReceipt } +interface EducationTableExportReceipt { state: string; targetFormat: string; targetFilename: string; bytes: number; rowCount: number; columnCount: number } +interface EducationImportRegistrySummary { importId: string; sourceFilename: string; sourceFormat: string; tableCount: number; importedAtUnixMs: number } +interface EducationAutomationRule { ruleId: string; title: string; tableId: string; conditionColumnId: string; operator: 'EQUALS' | 'CONTAINS' | 'IS_EMPTY' | 'IS_NOT_EMPTY'; conditionValue: string; actionColumnId: string; actionValue: string; enabled: boolean; revision: number; createdAtUnixMs: number; updatedAtUnixMs: number } +interface EducationAutomationPreview { state: string; ruleId: string; tableId: string; ruleRevision: number; tableRevision: number; matchedRows: number; changedCells: number; previewToken: string } +interface EducationAutomationRunReceipt { state: string; runId: string; ruleId: string; tableId: string; matchedRows: number; changedCells: number; fromTableRevision: number; toTableRevision: number; ranAtUnixMs: number } +type EducationDocumentTemplateId = 'blank' | 'lesson-plan' | 'meeting-notes' | 'student-review' +type EducationTableView = 'workbook' | 'grid' | 'cards' | 'board' | 'dashboard' +type EducationTableSortDirection = 'none' | 'ascending' | 'descending' +interface EducationWorkspaceSnapshot { + state: string + documents: EducationDocumentSummary[] + tables: EducationTableSummary[] + automationRules: EducationAutomationRule[] + recentAutomationRuns: EducationAutomationRunReceipt[] + recentImports: EducationImportRegistrySummary[] + documentCount: number + tableCount: number + automationRuleCount: number + storage: string + authority: string +} + +function newEducationAutomationDraft(table: EducationTable): EducationAutomationRule { + return { + ruleId: '', title: '未命名自动化规则', tableId: table.tableId, + conditionColumnId: table.columns[0]?.columnId || '', operator: 'EQUALS', conditionValue: '', + actionColumnId: table.columns.at(-1)?.columnId || '', actionValue: '已处理', enabled: true, + revision: 0, createdAtUnixMs: 0, updatedAtUnixMs: 0, + } +} + +const EDUCATION_DOCUMENT_TEMPLATES: Record = { + blank: { label: '空白文档', title: '未命名教育文档', body: '' }, + 'lesson-plan': { + label: '教学计划', + title: '教学计划', + body: '# 教学计划\n\n## 教学目标\n\n- \n\n## 教学对象\n\n\n## 课程安排\n\n| 时间 | 内容 | 负责人 |\n| --- | --- | --- |\n| | | |\n\n## 所需材料\n\n- \n\n## 课后复盘\n\n', + }, + 'meeting-notes': { + label: '会议纪要', + title: '教育项目会议纪要', + body: '# 教育项目会议纪要\n\n## 基本信息\n\n- 日期:\n- 参与人:\n- 主题:\n\n## 讨论事项\n\n1. \n\n## 决定与负责人\n\n| 事项 | 负责人 | 完成时间 | 状态 |\n| --- | --- | --- | --- |\n| | | | |\n\n## 待跟进\n\n- [ ] \n', + }, + 'student-review': { + label: '学员复盘', + title: '学员阶段复盘', + body: '# 学员阶段复盘\n\n## 本阶段目标\n\n\n## 已完成内容\n\n- \n\n## 学习表现\n\n\n## 当前困难\n\n\n## 下一阶段安排\n\n- [ ] \n', + }, +} + +function parseEducationGridPaste(source: string): string[][] { + return source.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n').map((line) => line.split('\t')) +} + +function isSensitiveEducationColumn(title: string): boolean { + const normalized = title.trim().toLocaleLowerCase().replace(/[\s_-]+/g, '') + return /(密码|口令|密钥|凭据|账号|账户|手机号|电话号码|联系电话|联系qq|qq号|身份证|银行卡|邮箱地址|secret|token|apikey|accesskey|credential|account|phone|mobile|email)/i.test(normalized) +} + +function educationNumericValue(value: string): number | null { + const normalized = value.trim().replace(/[\s,,¥¥$€£]/g, '').replace(/%$/, '') + if (!normalized || !/^-?\d+(?:\.\d+)?$/.test(normalized)) return null + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null +} + +function suggestEducationGroupColumn(table: EducationTable): number { + const preferred = /(类别|分类|状态|类型|渠道|班级|课程|负责人|书名|项目)/ + const preferredIndex = table.columns.findIndex((column) => !isSensitiveEducationColumn(column.title) && preferred.test(column.title)) + if (preferredIndex >= 0) return preferredIndex + return Math.max(0, table.columns.findIndex((column) => !isSensitiveEducationColumn(column.title))) +} + +function suggestEducationMeasureColumn(table: EducationTable): number { + let bestIndex = Math.max(0, table.columns.findIndex((column) => !isSensitiveEducationColumn(column.title))) + let bestScore = Number.NEGATIVE_INFINITY + table.columns.forEach((column, columnIndex) => { + if (isSensitiveEducationColumn(column.title)) return + const numericCount = table.rows.reduce((count, row) => count + (educationNumericValue(row.cells[columnIndex] || '') === null ? 0 : 1), 0) + const normalized = column.title.trim().toLocaleLowerCase().replace(/[\s_-]+/g, '') + const compensationContext = /(稿费|收入|薪酬|结算|营收)/.test(table.title) + let semanticScore = /(序号|编号|账号|电话|手机|日期|时间|^id$)/i.test(normalized) ? -200 + : /(实际收入|实收|到账)/.test(normalized) ? 170 + : /(当月收入|本月收入|稿费|结算金额)/.test(normalized) ? 160 + : /(累计收入|总收入|收入合计)/.test(normalized) ? 150 + : /(收入|营收|薪酬|金额|合计|总计)/.test(normalized) ? 120 + : /(成绩|分数|得分|数量|人数|课时|时长|成本|预算|进度|完成率)/.test(normalized) ? 90 : 0 + if (compensationContext && /(收入|稿费|实收|到账)/.test(normalized)) semanticScore += 35 + const score = semanticScore + (table.rows.length ? (numericCount / table.rows.length) * 30 : 0) + if (score > bestScore) { + bestScore = score + bestIndex = columnIndex + } + }) + return bestIndex +} + +function inferEducationSemanticFocus(table: EducationTable, measureIndex: number): { state: 'inferred' | 'needs-selection'; title: string; reason: string } { + const measureTitle = table.columns[measureIndex]?.title || '' + const compensationContext = /(稿费|收入|薪酬|结算|营收)/.test(table.title) + if (compensationContext && /(收入|稿费|实收|到账)/.test(measureTitle)) return { state: 'inferred', title: '稿费收入', reason: `表名与字段“${measureTitle}”共同指向收入主题` } + if (/(成绩|考试|测评|学员)/.test(table.title) && /(成绩|分数|得分)/.test(measureTitle)) return { state: 'inferred', title: '学习成绩', reason: `表名与字段“${measureTitle}”共同指向成绩主题` } + if (/(考勤|出勤|签到)/.test(table.title) && /(考勤|出勤|签到|次数|时长)/.test(measureTitle)) return { state: 'inferred', title: '出勤情况', reason: `表名与字段“${measureTitle}”共同指向考勤主题` } + if (/(收入|金额|成绩|分数|数量|人数|课时|时长|成本|预算|进度|完成率|合计|总计)/.test(measureTitle)) return { state: 'inferred', title: measureTitle, reason: `字段“${measureTitle}”具备可计算的业务语义` } + return { state: 'needs-selection', title: '重点待选择', reason: '系统没有足够把握决定这份数据最重要的指标,请选择统计字段' } +} + interface CodeChannelEntry { channelId: string; name: string; sourceKind: string; localPath: string; remoteUrl?: string; gitHead: string; branch: string; repositoryClean: boolean; registeredAtUnixMs: number } interface CodeChannelSnapshot { state: string; channels: CodeChannelEntry[]; authority: string } interface CodeTreeEntry { path: string; name: string; kind: 'directory' | 'file'; sizeBytes: number } @@ -346,7 +482,8 @@ interface EnterpriseReceiptEnvelope { const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'READY_HUMAN_CONFIRMATION_REQUIRED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', terminalLinkProtocol: 'HOLOLAKE_TERMINAL_LINK/2', terminalLinkTransport: 'UNVERIFIED', environmentFramePolicy: 'REQUIRED_AFTER_CONNECT_RESUME_AND_BEFORE_MUTATION' } const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], modules: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } } -const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, truncated: false } +const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, organizedDocumentCount: 0, categories: [], tags: [], organizationRevision: '', truncated: false } +const previewEducationWorkspace: EducationWorkspaceSnapshot = { state: 'UNAVAILABLE', documents: [], tables: [], automationRules: [], recentAutomationRuns: [], recentImports: [], documentCount: 0, tableCount: 0, automationRuleCount: 0, storage: 'AUTHENTICATED_ACCOUNT_REQUIRED', authority: 'CURRENT_AUTHENTICATED_ACCOUNT_ONLY' } const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], authority: 'LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY' } const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, @@ -354,7 +491,7 @@ const themes: Array<{ id: ThemeId; name: string }> = [ const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', 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'], + ['域标识', 'BRANCH_DOMAIN'], ['系统职责', '行业入口与行业路由'], ['行业状态', '网文 · 待开发 / 宠物 · 待开发 / 教育 · 开发中'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], ] }, { domain: 'MAIN_DOMAIN', className: 'd-main', title: '光湖主域', gate: 'GATE 01 · ONLINE', facts: [ ['域标识', 'MAIN_DOMAIN'], ['责任主体', 'Awen · TCS-GL-0016∞'], ['人格体主体', '天枢 · PER-AW-ARCH-001 · AGE'], ['关系支持', '知秋 · PER-ZQ001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], @@ -396,8 +533,8 @@ function LakeAtmosphere({ awake }: { awake: boolean }) { } -function LakePool({ className, title, meta, open = false, risen = false, onClick }: { className: string; title: string; meta: string; open?: boolean; risen?: boolean; onClick?: () => void }) { - return @@ -441,7 +578,7 @@ function getOrCreateLocalId(key: string, prefix: string) { return generated } -function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system' | 'login') { +function humanError(error: unknown, context: 'knowledge' | 'education' | 'code' | 'identity' | 'system' | 'login') { const value = String(error) if (value.includes('LOGIN_CREDENTIALS_INVALID')) return '账号或密码未被仓库接受。' if (value.includes('LOGIN_USERNAME_INVALID')) return '账号格式不合法(仅限字母、数字、连字符、下划线)。' @@ -467,10 +604,15 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | if (value.includes('CLONE_FAILED')) return '代码频道克隆失败,请核对地址及读取权限。' if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。' if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。' + if (value.includes('EDUCATION_DOCUMENT_REVISION_CONFLICT')) return '这篇教育文档已经被更新,请重新打开后再保存。' + if (value.includes('EDUCATION_TABLE_REVISION_CONFLICT')) return '这张教育表格已经被更新,请重新打开后再保存。' + if (value.includes('AUTHENTICATED_ACCOUNT_REQUIRED')) return '请先完成编号验证和账号登录,再使用教育工作模块。' + if (value.includes('EDUCATION_TABLE_BOUNDS_INVALID')) return '表格最多支持 30 列、1000 行。' if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。' if (context === 'login') return '登录未完成。' if (context === 'identity') return '个人空间未能建立。' if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。' + if (context === 'education') return '教育工作空间操作未完成,已有内容没有被覆盖。' if (context === 'code') return '代码频道操作未完成。' return '系统操作未完成。' } @@ -571,12 +713,47 @@ function HoloLakeApp() { .catch(() => undefined) }, []) const [knowledge, setKnowledge] = useState(previewKnowledge) + const [educationWorkspace, setEducationWorkspace] = useState(previewEducationWorkspace) + const [activeEducationDocument, setActiveEducationDocument] = useState(null) + const [activeEducationTable, setActiveEducationTable] = useState(null) + const [activeEducationAutomationRule, setActiveEducationAutomationRule] = useState(null) + const [educationAutomationPreview, setEducationAutomationPreview] = useState(null) + const [educationBusy, setEducationBusy] = useState(false) + const [educationMessage, setEducationMessage] = useState('') + const [educationImportOutcome, setEducationImportOutcome] = useState(null) + const [educationDocumentQuery, setEducationDocumentQuery] = useState('') + const [educationTableDirectoryQuery, setEducationTableDirectoryQuery] = useState('') + const [educationTableQuery, setEducationTableQuery] = useState('') + const [educationDocumentTemplate, setEducationDocumentTemplate] = useState('blank') + const [educationDocumentMode, setEducationDocumentMode] = useState<'read' | 'edit'>('read') + const [educationDocumentSettingsOpen, setEducationDocumentSettingsOpen] = useState(false) + const [educationTableSettingsOpen, setEducationTableSettingsOpen] = useState(false) + const educationTableTitleRef = useRef(null) + const [educationTableView, setEducationTableView] = useState('workbook') + const [educationSensitiveVisible, setEducationSensitiveVisible] = useState(true) + const [educationTableSortColumn, setEducationTableSortColumn] = useState(0) + const [educationTableSortDirection, setEducationTableSortDirection] = useState('none') + const [educationTablePageIndex, setEducationTablePageIndex] = useState(0) + const [educationTableGroupColumn, setEducationTableGroupColumn] = useState(0) + const [educationTableMeasureColumn, setEducationTableMeasureColumn] = useState(0) + const handleEducationWorkbookChange = useCallback((value: { columns: EducationTableColumn[]; rows: EducationTableRow[] }) => { + setActiveEducationTable((current) => current ? { ...current, columns: value.columns, rows: value.rows } : current) + }, []) + const [educationComposition, setEducationComposition] = useState(null) + const [compositionDimension, setCompositionDimension] = useState('TOP_LEVEL_FOLDER') + const [compositionMeasure, setCompositionMeasure] = useState('DOCUMENT_COUNT') + const [compositionViews, setCompositionViews] = useState(['DASHBOARD', 'COMPARISON', 'VERTICAL_BAR', 'CLASSIFICATION', 'TABLE']) const [codeChannels, setCodeChannels] = useState(previewCode) const [activeDocument, setActiveDocument] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState(null) const [knowledgeBusy, setKnowledgeBusy] = useState(false) const [knowledgeMessage, setKnowledgeMessage] = useState('') + const [knowledgeBrowseMode, setKnowledgeBrowseMode] = useState<'tree' | 'organize'>('tree') + const [knowledgeCategoryFilter, setKnowledgeCategoryFilter] = useState('') + const [knowledgeTagFilter, setKnowledgeTagFilter] = useState('') + const [knowledgeOrganizationCategory, setKnowledgeOrganizationCategory] = useState('根目录') + const [knowledgeOrganizationTags, setKnowledgeOrganizationTags] = useState('') const [expanded, setExpanded] = useState>(() => new Set(['导入'])) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -603,6 +780,7 @@ function HoloLakeApp() { const [systemMessage, setSystemMessage] = useState('') const [releaseCandidate, setReleaseCandidate] = useState(null) const [repoLogin, setRepoLogin] = useState(null) + const [showMainWorld, setShowMainWorld] = useState(false) const [userPncc, setUserPncc] = useState(null) const [enterpriseWork, setEnterpriseWork] = useState(null) const [userPnccBusy, setUserPnccBusy] = useState(false) @@ -845,13 +1023,21 @@ function HoloLakeApp() { useEffect(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view]) const visibleDocuments = useMemo(() => { - if (!searchResults) return knowledge.documents - return searchResults.map((result) => knowledge.documents.find((item) => item.source === result.source && item.path === result.path)).filter(Boolean) as KnowledgeDocumentSummary[] - }, [knowledge.documents, searchResults]) + const searched = !searchResults + ? knowledge.documents + : searchResults.map((result) => knowledge.documents.find((item) => item.source === result.source && item.path === result.path)).filter(Boolean) as KnowledgeDocumentSummary[] + return searched.filter((document) => + (!knowledgeCategoryFilter || document.category === knowledgeCategoryFilter) + && (!knowledgeTagFilter || document.tags.includes(knowledgeTagFilter))) + }, [knowledge.documents, knowledgeCategoryFilter, knowledgeTagFilter, searchResults]) const tree = useMemo(() => knowledgeTree(visibleDocuments), [visibleDocuments]) const activeSummary = activeDocument ? knowledge.documents.find((item) => item.source === activeDocument.source && item.path === activeDocument.path) : undefined + useEffect(() => { + setKnowledgeOrganizationCategory(activeSummary?.category || '根目录') + setKnowledgeOrganizationTags(activeSummary?.tags.join(',') || '') + }, [activeSummary?.category, activeSummary?.path, activeSummary?.tags]) const parsedDocument = useMemo(() => activeDocument ? splitFrontmatter(activeDocument.body) : null, [activeDocument]) const outline = useMemo(() => activeDocument ? documentOutline(activeDocument.body) : [], [activeDocument]) const docStats = useMemo(() => { @@ -971,10 +1157,11 @@ function HoloLakeApp() { } const removeDocument = async () => { if (!activeDocument) return + if (!window.confirm(`把知识页「${activeDocument.title}」移入可恢复归档?`)) return setKnowledgeBusy(true) setKnowledgeMessage('') try { - const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_document', { input: { source: activeDocument.source, path: activeDocument.path } }) + const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_document', { input: { source: activeDocument.source, path: activeDocument.path, confirmed: true } }) if (receipt) { setKnowledgeMessage(`已移入回收站:${receipt.removed}(可找回)`) setActiveDocument(null) @@ -985,10 +1172,11 @@ function HoloLakeApp() { finally { setKnowledgeBusy(false) } } const removeFolder = async (folder: TreeNode) => { + if (!window.confirm(`把知识文件夹「${folder.name}」和其中 ${countTree(folder)} 篇页面移入可恢复归档?`)) return setKnowledgeBusy(true) setKnowledgeMessage('') try { - const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_folder', { input: { folder: folder.key } }) + const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_folder', { input: { folder: folder.key, confirmed: true } }) if (receipt) { setKnowledgeMessage(`文件夹「${folder.name}」(${receipt.pages} 页)已移入回收站(可找回)`) setActiveDocument(null) @@ -1015,6 +1203,506 @@ function HoloLakeApp() { finally { setKnowledgeBusy(false) } } + const saveKnowledgeOrganization = async () => { + if (!activeDocument?.writable) return + setKnowledgeBusy(true) + setKnowledgeMessage('') + try { + const tags = knowledgeOrganizationTags.split(/[,,#\n]/).map((tag) => tag.trim()).filter(Boolean) + const result = await invoke<{ state: string; gitCommit: string; snapshot: KnowledgeSnapshot }>('update_knowledge_organization', { + input: { + source: activeDocument.source, + path: activeDocument.path, + expectedContentSha256: activeDocument.contentSha256, + category: knowledgeOrganizationCategory, + tags, + }, + }) + setKnowledge(result.snapshot) + setLastKnowledgeReceipt(result.gitCommit) + setKnowledgeMessage(result.state === 'UNCHANGED' ? '分类与标签没有变化。' : '分类与标签已保存;正文和原目录没有被改动。') + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } + } + + const refreshEducationWorkspace = async () => { + const snapshot = await invoke('get_education_workspace_snapshot') + setEducationWorkspace(snapshot) + return snapshot + } + const openEducationDocument = async (documentId: string) => { + setEducationBusy(true) + setEducationMessage('') + try { + setActiveEducationDocument(await invoke('read_education_document', { input: { documentId } })) + setEducationDocumentMode('read') + setEducationDocumentSettingsOpen(false) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const openEducationTable = async (tableId: string) => { + setEducationBusy(true) + setEducationMessage('') + try { + const table = await invoke('read_education_table', { input: { tableId } }) + setActiveEducationTable(table) + setEducationTableGroupColumn(suggestEducationGroupColumn(table)) + setEducationTableMeasureColumn(suggestEducationMeasureColumn(table)) + setEducationTableSettingsOpen(false) + setEducationSensitiveVisible(true) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const executeEducationComposition = async () => { + setEducationBusy(true) + setEducationMessage('') + try { + const projection = await invoke('execute_knowledge_native_composition', { input: { dimension: compositionDimension, measure: compositionMeasure, views: compositionViews } }) + setEducationComposition(projection) + setEducationMessage(`组合已完成:${projection.nativeObject.rowCount} 条真实知识对象进入 ${projection.views.length} 种投影。`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const openEducationModule = async (stage: 'educationDocuments' | 'educationTables' | 'educationData' | 'educationAutomation' | 'educationComposition') => { + if (!repoLogin) { + setEducationMessage('请先完成编号验证和账号登录,再使用教育工作模块。') + return + } + setEducationBusy(true) + setEducationMessage('') + setWorldStage(stage) + try { + const snapshot = await refreshEducationWorkspace() + if (stage === 'educationComposition') { + const projection = await invoke('execute_knowledge_native_composition', { input: { dimension: compositionDimension, measure: compositionMeasure, views: compositionViews } }) + setEducationComposition(projection) + setEducationMessage(`已读取当前账号 ${projection.nativeObject.rowCount} 条真实知识对象。`) + } + if (stage === 'educationDocuments' && snapshot.documents.length && !activeEducationDocument) { + setActiveEducationDocument(await invoke('read_education_document', { input: { documentId: snapshot.documents[0].documentId } })) + } + if ((stage === 'educationTables' || stage === 'educationData' || stage === 'educationAutomation') && snapshot.tables.length) { + const largestTable = [...snapshot.tables].sort((left, right) => right.rowCount - left.rowCount || right.columnCount - left.columnCount)[0] + const preferredTableId = stage === 'educationAutomation' && snapshot.automationRules.length ? snapshot.automationRules[0].tableId : activeEducationTable?.tableId || largestTable.tableId + const table = await invoke('read_education_table', { input: { tableId: preferredTableId } }) + setActiveEducationTable(table) + setEducationTableGroupColumn(suggestEducationGroupColumn(table)) + setEducationTableMeasureColumn(suggestEducationMeasureColumn(table)) + setEducationSensitiveVisible(true) + if (stage === 'educationAutomation') { + setActiveEducationAutomationRule(snapshot.automationRules[0] || newEducationAutomationDraft(table)) + setEducationAutomationPreview(null) + } + } + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const createEducationDocument = async (templateId: EducationDocumentTemplateId = educationDocumentTemplate) => { + setEducationBusy(true) + setEducationMessage('') + try { + const template = EDUCATION_DOCUMENT_TEMPLATES[templateId] + let document = await invoke('create_education_document', { input: { title: template.title } }) + if (template.body) { + document = await invoke('save_education_document', { + input: { + documentId: document.documentId, + title: template.title, + body: template.body, + expectedRevision: document.revision, + }, + }) + } + setActiveEducationDocument(document) + setEducationDocumentMode('edit') + setEducationDocumentSettingsOpen(false) + await refreshEducationWorkspace() + setEducationMessage(templateId === 'blank' ? '教育文档已创建,可以直接编辑并保存。' : `已从「${template.label}」建立教育文档。`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const saveEducationDocument = async () => { + if (!activeEducationDocument) return + setEducationBusy(true) + setEducationMessage('') + try { + const document = await invoke('save_education_document', { + input: { + documentId: activeEducationDocument.documentId, + title: activeEducationDocument.title, + body: activeEducationDocument.body, + expectedRevision: activeEducationDocument.revision, + }, + }) + setActiveEducationDocument(document) + setEducationDocumentMode('read') + await refreshEducationWorkspace() + setEducationMessage(`已保存 · 修订 ${document.revision}`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const archiveEducationDocument = async () => { + if (!activeEducationDocument || !window.confirm(`把教育文档「${activeEducationDocument.title}」移入可恢复归档?`)) return + setEducationBusy(true) + setEducationMessage('') + try { + await invoke('archive_education_document', { input: { documentId: activeEducationDocument.documentId } }) + setEducationDocumentSettingsOpen(false) + setActiveEducationDocument(null) + const snapshot = await refreshEducationWorkspace() + if (snapshot.documents.length) await openEducationDocument(snapshot.documents[0].documentId) + setEducationMessage('教育文档已移入可恢复归档。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const duplicateEducationDocument = async () => { + if (!activeEducationDocument) return + setEducationBusy(true) + setEducationMessage('') + try { + let document = await invoke('create_education_document', { input: { title: `${activeEducationDocument.title} · 副本` } }) + document = await invoke('save_education_document', { input: { + documentId: document.documentId, + title: `${activeEducationDocument.title} · 副本`, + body: activeEducationDocument.body, + expectedRevision: document.revision, + } }) + setActiveEducationDocument(document) + setEducationDocumentMode('edit') + setEducationDocumentSettingsOpen(false) + await refreshEducationWorkspace() + setEducationMessage('副本已建立,修改后保存即可。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const exportEducationDocument = async (format: 'md' | 'html') => { + if (!activeEducationDocument) return + setEducationBusy(true) + setEducationMessage('') + try { + let body = activeEducationDocument.body + if (format === 'html') { + const title = activeEducationDocument.title.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + body = `${title}

${title}

${markdownHtml(activeEducationDocument.body)}` + } + const receipt = await invoke<{ path: string; bytes: number } | null>('export_knowledge_document', { input: { title: activeEducationDocument.title, extension: format, body } }) + setEducationDocumentSettingsOpen(false) + setEducationMessage(receipt ? `文档已导出到 ${receipt.path}` : '已取消导出。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const createEducationTable = async () => { + setEducationBusy(true) + setEducationMessage('') + try { + const table = await invoke('create_education_table', { input: { title: '未命名教育表格' } }) + setActiveEducationTable(table) + setEducationSensitiveVisible(true) + setEducationTableGroupColumn(0) + setEducationTableMeasureColumn(0) + await refreshEducationWorkspace() + setEducationMessage('教育表格已创建,可以直接录入数据。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const importEducationTables = async () => { + setEducationBusy(true) + setEducationMessage('') + try { + const outcome = await invoke('import_education_tables_from_dialog') + if (!outcome) { + setEducationMessage('已取消导入,原文件没有变化。') + return + } + setEducationImportOutcome(outcome) + if (outcome.importReceipt) { + const receipt = outcome.importReceipt + const snapshot = await refreshEducationWorkspace() + const firstTable = receipt.importedTables[0] + if (firstTable) { + const table = await invoke('read_education_table', { input: { tableId: firstTable.tableId } }) + setActiveEducationTable(table) + setEducationSensitiveVisible(true) + setEducationTableGroupColumn(suggestEducationGroupColumn(table)) + setEducationTableMeasureColumn(suggestEducationMeasureColumn(table)) + } + setEducationMessage(`已先体检 ${receipt.contentProfile.pageCount} 页,再登记为 ${receipt.importedTables.length} 张 HoloLake 原生表格;目录现有 ${snapshot.tableCount} 张。`) + } else if (outcome.assistanceReceipt) { + setEducationMessage(outcome.assistanceReceipt.title) + } + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const exportEducationTable = async (format: 'XLSX' | 'CSV' | 'TSV' | 'HOLOLAKE_NATIVE') => { + if (!activeEducationTable) return + setEducationBusy(true) + setEducationMessage('') + try { + const receipt = await invoke('export_education_table_to_dialog', { input: { + format, + table: { + tableId: activeEducationTable.tableId, + title: activeEducationTable.title, + columns: activeEducationTable.columns, + rows: activeEducationTable.rows, + revision: activeEducationTable.revision, + }, + } }) + setEducationTableSettingsOpen(false) + setEducationMessage(receipt ? `已从原生表格转译并导出 ${receipt.targetFilename} · ${receipt.rowCount} 行 × ${receipt.columnCount} 列。` : '已取消导出。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const explainModelRecognitionSlot = () => { + const assistance = educationImportOutcome?.assistanceReceipt + if (!assistance) return + window.alert(assistance.modelAssistanceEligible + ? '当前 HoloLake 尚未配置模型识别接口。受支持文件仍可正常导入本机 HoloLake;当前文件没有发送给外部模型。以后绑定用户自己的 API 后,仍需对当前文件单独确认,才会交给模型重新识别。新规则可选择仅自己使用,或去隐私后提交公共共享。' + : '这次停止来自本机安全上限,模型接口不会绕过容量限制。请先拆分或缩小文件后再导入。') + } + const explainEducationModelEnhancement = () => { + window.alert('当前运行的是本地基础算法:文件可以正常导入本机 HoloLake,并依据文件名、字段语义、结构和数值覆盖率做可解释判断。模型 API 与 Agent 人格体执行层属于下一阶段;配置用户自己的 API 后,仍需明确同意当前文件,才会把文件发送给外部模型,让人格体结合任务上下文重新识别重点并编排视图。未配置时只是不向外部模型发送文件,不影响本地导入。') + } + const duplicateEducationTable = async () => { + if (!activeEducationTable) return + setEducationBusy(true) + setEducationMessage('') + try { + let table = await invoke('create_education_table', { input: { title: `${activeEducationTable.title} · 副本` } }) + const columns = activeEducationTable.columns.map((column) => ({ columnId: `COL-${crypto.randomUUID()}`, title: column.title })) + const rows = activeEducationTable.rows.map((row) => ({ rowId: `ROW-${crypto.randomUUID()}`, cells: [...row.cells] })) + table = await invoke('save_education_table', { input: { + tableId: table.tableId, + title: `${activeEducationTable.title} · 副本`, + columns, + rows, + expectedRevision: table.revision, + } }) + setActiveEducationTable(table) + setEducationTableSettingsOpen(false) + await refreshEducationWorkspace() + setEducationMessage('表格副本已建立,可以继续编辑。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const saveEducationTable = async () => { + if (!activeEducationTable) return + setEducationBusy(true) + setEducationMessage('') + try { + const table = await invoke('save_education_table', { + input: { + tableId: activeEducationTable.tableId, + title: activeEducationTable.title, + columns: activeEducationTable.columns, + rows: activeEducationTable.rows, + expectedRevision: activeEducationTable.revision, + }, + }) + setActiveEducationTable(table) + await refreshEducationWorkspace() + setEducationMessage(`已保存 · ${table.rows.length} 行 · 修订 ${table.revision}`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const archiveEducationTable = async () => { + if (!activeEducationTable || !window.confirm(`把教育表格「${activeEducationTable.title}」移入可恢复归档?`)) return + setEducationBusy(true) + setEducationMessage('') + try { + await invoke('archive_education_table', { input: { tableId: activeEducationTable.tableId } }) + setEducationTableSettingsOpen(false) + setActiveEducationTable(null) + const snapshot = await refreshEducationWorkspace() + if (snapshot.tables.length) await openEducationTable(snapshot.tables[0].tableId) + setEducationMessage('教育表格已移入可恢复归档。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const addEducationTableColumn = () => { + setActiveEducationTable((current) => { + if (!current || current.columns.length >= 30) return current + return { + ...current, + columns: [...current.columns, { columnId: `COL-${crypto.randomUUID()}`, title: `新字段 ${current.columns.length + 1}` }], + rows: current.rows.map((row) => ({ ...row, cells: [...row.cells, ''] })), + } + }) + } + const removeEducationTableColumn = (columnIndex: number) => { + setActiveEducationTable((current) => { + if (!current || current.columns.length <= 1) return current + return { + ...current, + columns: current.columns.filter((_, index) => index !== columnIndex), + rows: current.rows.map((row) => ({ ...row, cells: row.cells.filter((_, index) => index !== columnIndex) })), + } + }) + } + const addEducationTableRow = () => { + setActiveEducationTable((current) => { + if (!current || current.rows.length >= 1000) return current + return { + ...current, + rows: [...current.rows, { rowId: `ROW-${crypto.randomUUID()}`, cells: current.columns.map(() => '') }], + } + }) + } + const pasteEducationTableGrid = (rowIndex: number, columnIndex: number, source: string) => { + const pasted = parseEducationGridPaste(source) + if (!pasted.length || (pasted.length === 1 && pasted[0].length === 1)) return false + setActiveEducationTable((current) => { + if (!current) return current + const requiredColumnCount = Math.min(30, Math.max(current.columns.length, columnIndex + Math.max(...pasted.map((row) => row.length)))) + const requiredRowCount = Math.min(1000, Math.max(current.rows.length, rowIndex + pasted.length)) + const columns = [...current.columns] + while (columns.length < requiredColumnCount) columns.push({ columnId: `COL-${crypto.randomUUID()}`, title: `新字段 ${columns.length + 1}` }) + const rows = current.rows.map((row) => ({ ...row, cells: [...row.cells, ...Array(Math.max(0, requiredColumnCount - row.cells.length)).fill('')] })) + while (rows.length < requiredRowCount) rows.push({ rowId: `ROW-${crypto.randomUUID()}`, cells: columns.map(() => '') }) + pasted.slice(0, requiredRowCount - rowIndex).forEach((sourceRow, pastedRowIndex) => { + sourceRow.slice(0, requiredColumnCount - columnIndex).forEach((cell, pastedColumnIndex) => { + rows[rowIndex + pastedRowIndex].cells[columnIndex + pastedColumnIndex] = cell.slice(0, 10000) + }) + }) + return { ...current, columns, rows } + }) + setEducationMessage(`已批量填入 ${Math.min(pasted.length, 1000 - rowIndex)} 行数据,保存后写入本机数据库。`) + return true + } + const applyEducationDataCleanup = async () => { + if (!activeEducationTable) return + const analysis = analyzeEducationTableData(activeEducationTable) + const changed = analysis.emptyRows + analysis.duplicateRows + analysis.trimmedCells + if (!changed) { setEducationMessage('当前表格没有需要整理的数据。'); return } + if (!window.confirm(`将整理「${activeEducationTable.title}」:清除 ${analysis.emptyRows} 个空行、${analysis.duplicateRows} 个重复行,并规范 ${analysis.trimmedCells} 个单元格的首尾空白。是否保存?`)) return + setEducationBusy(true) + setEducationMessage('') + try { + const table = await invoke('save_education_table', { + input: { + tableId: activeEducationTable.tableId, + title: activeEducationTable.title, + columns: activeEducationTable.columns, + rows: analysis.cleanedRows, + expectedRevision: activeEducationTable.revision, + }, + }) + setActiveEducationTable(table) + await refreshEducationWorkspace() + setEducationMessage(`数据整理已保存 · ${analysis.originalRows} 行 → ${table.rows.length} 行 · 修订 ${table.revision}`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + + const updateEducationAutomationRule = (patch: Partial) => { + setActiveEducationAutomationRule((current) => current ? { ...current, ...patch } : current) + setEducationAutomationPreview(null) + } + const selectEducationAutomationRule = async (rule: EducationAutomationRule) => { + setEducationBusy(true) + setEducationMessage('') + try { + const table = await invoke('read_education_table', { input: { tableId: rule.tableId } }) + setActiveEducationTable(table) + setActiveEducationAutomationRule(rule) + setEducationAutomationPreview(null) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const selectEducationAutomationTable = async (tableId: string) => { + setEducationBusy(true) + setEducationMessage('') + try { + const table = await invoke('read_education_table', { input: { tableId } }) + setActiveEducationTable(table) + setActiveEducationAutomationRule((current) => current ? { + ...current, tableId, conditionColumnId: table.columns[0]?.columnId || '', actionColumnId: table.columns.at(-1)?.columnId || '', + } : newEducationAutomationDraft(table)) + setEducationAutomationPreview(null) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const createNewEducationAutomationDraft = async () => { + const summary = educationWorkspace.tables[0] + if (!summary) { setEducationMessage('请先建立一张教育表格,再创建自动化规则。'); return } + const table = activeEducationTable?.tableId === summary.tableId ? activeEducationTable : await invoke('read_education_table', { input: { tableId: summary.tableId } }) + setActiveEducationTable(table) + setActiveEducationAutomationRule(newEducationAutomationDraft(table)) + setEducationAutomationPreview(null) + setEducationMessage('新规则尚未写入数据库,填写完成后选择“建立规则”。') + } + const saveEducationAutomationRule = async () => { + if (!activeEducationAutomationRule || !activeEducationTable) return + setEducationBusy(true) + setEducationMessage('') + try { + const rule = activeEducationAutomationRule.ruleId + ? await invoke('save_education_automation_rule', { input: { + ruleId: activeEducationAutomationRule.ruleId, title: activeEducationAutomationRule.title, + tableId: activeEducationAutomationRule.tableId, conditionColumnId: activeEducationAutomationRule.conditionColumnId, + operator: activeEducationAutomationRule.operator, conditionValue: activeEducationAutomationRule.conditionValue, + actionColumnId: activeEducationAutomationRule.actionColumnId, actionValue: activeEducationAutomationRule.actionValue, + enabled: activeEducationAutomationRule.enabled, expectedRevision: activeEducationAutomationRule.revision, + } }) + : await invoke('create_education_automation_rule', { input: { + title: activeEducationAutomationRule.title, tableId: activeEducationAutomationRule.tableId, + conditionColumnId: activeEducationAutomationRule.conditionColumnId, operator: activeEducationAutomationRule.operator, + conditionValue: activeEducationAutomationRule.conditionValue, actionColumnId: activeEducationAutomationRule.actionColumnId, + actionValue: activeEducationAutomationRule.actionValue, + } }) + setActiveEducationAutomationRule(rule) + setEducationAutomationPreview(null) + await refreshEducationWorkspace() + setEducationMessage(`自动化规则已保存 · 修订 ${rule.revision}`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const archiveEducationAutomationRule = async () => { + if (!activeEducationAutomationRule?.ruleId || !window.confirm(`把自动化规则「${activeEducationAutomationRule.title}」移入可恢复归档?`)) return + setEducationBusy(true) + setEducationMessage('') + try { + await invoke('archive_education_automation_rule', { input: { ruleId: activeEducationAutomationRule.ruleId } }) + const snapshot = await refreshEducationWorkspace() + const nextRule = snapshot.automationRules[0] || (activeEducationTable ? newEducationAutomationDraft(activeEducationTable) : null) + setActiveEducationAutomationRule(nextRule) + setEducationAutomationPreview(null) + setEducationMessage('自动化规则已移入可恢复归档。') + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const previewEducationAutomationRule = async () => { + if (!activeEducationAutomationRule?.ruleId || !activeEducationTable) { setEducationMessage('请先建立并保存规则,再预览执行范围。'); return } + setEducationBusy(true) + setEducationMessage('') + try { + const preview = await invoke('preview_education_automation_rule', { input: { + ruleId: activeEducationAutomationRule.ruleId, expectedRuleRevision: activeEducationAutomationRule.revision, + expectedTableRevision: activeEducationTable.revision, + } }) + setEducationAutomationPreview(preview) + setEducationMessage(`预览完成 · 匹配 ${preview.matchedRows} 行 · 将修改 ${preview.changedCells} 个单元格`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const executeEducationAutomationRule = async () => { + if (!activeEducationAutomationRule || !activeEducationTable || !educationAutomationPreview) return + if (!window.confirm(`规则「${activeEducationAutomationRule.title}」将匹配 ${educationAutomationPreview.matchedRows} 行,修改 ${educationAutomationPreview.changedCells} 个单元格。确认运行?`)) return + setEducationBusy(true) + setEducationMessage('') + try { + const receipt = await invoke('execute_education_automation_rule', { input: { + ruleId: activeEducationAutomationRule.ruleId, expectedRuleRevision: educationAutomationPreview.ruleRevision, + expectedTableRevision: educationAutomationPreview.tableRevision, previewToken: educationAutomationPreview.previewToken, + } }) + setActiveEducationTable(await invoke('read_education_table', { input: { tableId: receipt.tableId } })) + await refreshEducationWorkspace() + setEducationAutomationPreview(null) + setEducationMessage(`执行完成 · ${receipt.changedCells} 个单元格已处理 · 回执 ${receipt.runId.slice(-8)}`) + } catch (error) { setEducationMessage(humanError(error, 'education')) } + finally { setEducationBusy(false) } + } + const cloneCodeChannel = async (event: React.FormEvent) => { event.preventDefault() if (!cloneUrl.trim()) return @@ -1083,6 +1771,10 @@ function HoloLakeApp() { // 账号切换时先清空上一账号的内存投影,绝不让旧页面在新会话首帧闪现。 setPersonal(previewPersonal) setKnowledge(previewKnowledge) + setEducationWorkspace(previewEducationWorkspace) + setActiveEducationDocument(null) + setActiveEducationTable(null) + setEducationMessage('') setCodeChannels(previewCode) setActiveDocument(null) setActiveChannel(null) @@ -1090,6 +1782,7 @@ function HoloLakeApp() { setActiveCodeFile(null) setReceipts([]) setRepoLogin({ username: receipt.username, host: receipt.host, domain: receipt.domain, signedInAtUnixMs: Date.now() }) + setShowMainWorld(false) setWorldStage('domain') setView('overview') setLoginRising(false) @@ -1123,6 +1816,10 @@ function HoloLakeApp() { try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ } setPersonal(previewPersonal) setKnowledge(previewKnowledge) + setEducationWorkspace(previewEducationWorkspace) + setActiveEducationDocument(null) + setActiveEducationTable(null) + setEducationMessage('') setCodeChannels(previewCode) setActiveDocument(null) setActiveChannel(null) @@ -1133,8 +1830,24 @@ function HoloLakeApp() { setEnterpriseWork(null) setEnterpriseReceiptMessage('') setWorldStage('domain') + setShowMainWorld(false) setRepoLogin(null) } + const returnToMainWorld = () => { + setWorldStage('domain') + setToolReturnStage('channel') + setGateStage('number') + setGateOpen(false) + setActiveDomainInfo('') + setGateMessage('') + setGateRising(false) + setShowMainWorld(true) + } + const enterAuthenticatedDomain = () => { + setActiveDomainInfo('') + setWorldStage('domain') + setShowMainWorld(false) + } const confirmEnterpriseRelationship = async (decision: 'CONFIRM' | 'REJECT') => { setEnterpriseReceiptBusy(true) setEnterpriseReceiptMessage('') @@ -1280,6 +1993,361 @@ function HoloLakeApp() { setZpBusy(true); setZpMessage('') try { setZeroPoint(await invoke('zero_point_sync')) } catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) } } + const visibleEducationDocuments = educationWorkspace.documents.filter((document) => document.title.toLocaleLowerCase('zh-CN').includes(educationDocumentQuery.trim().toLocaleLowerCase('zh-CN'))) + const visibleEducationTables = educationWorkspace.tables.filter((table) => table.title.toLocaleLowerCase('zh-CN').includes(educationTableDirectoryQuery.trim().toLocaleLowerCase('zh-CN'))) + const educationTableEngineActive = worldStage === 'educationTables' + const educationEngineData = useMemo(() => educationTableEngineActive ? activeEducationTable?.rows || [] : [], [activeEducationTable?.rows, educationTableEngineActive]) + const educationEngineColumns = useMemo[]>(() => (educationTableEngineActive ? activeEducationTable?.columns || [] : []).map((column, columnIndex) => ({ + id: column.columnId, + header: column.title || `字段 ${columnIndex + 1}`, + accessorFn: (row) => row.cells[columnIndex] || '', + sortingFn: 'alphanumeric', + aggregationFn: 'count', + })), [activeEducationTable?.columns, educationTableEngineActive]) + const educationEngineSorting = useMemo(() => { + if (!activeEducationTable || educationTableSortDirection === 'none') return [] + const columnIndex = Math.min(educationTableSortColumn, Math.max(0, activeEducationTable.columns.length - 1)) + const columnId = activeEducationTable.columns[columnIndex]?.columnId + return columnId ? [{ id: columnId, desc: educationTableSortDirection === 'descending' }] : [] + }, [activeEducationTable, educationTableSortColumn, educationTableSortDirection]) + const educationAdaptiveRenderPlan = useMemo(() => { + const table = activeEducationTable + if (!table) return buildEducationBaselineRenderPlan({ rowCount: 0, columnCount: 1, numericColumnCount: 0, groupableColumnCount: 0, focusConfidence: 'low' }) + const numericColumnCount = table.columns.filter((_, columnIndex) => table.rows.some((row) => educationNumericValue(row.cells[columnIndex] || '') !== null)).length + const groupableColumnCount = table.columns.filter((column, columnIndex) => { + if (isSensitiveEducationColumn(column.title)) return false + const values = new Set(table.rows.map((row) => (row.cells[columnIndex] || '').trim()).filter(Boolean)) + return values.size > 1 && values.size <= Math.max(12, Math.floor(table.rows.length * 0.6)) + }).length + return buildEducationBaselineRenderPlan({ rowCount: table.rows.length, columnCount: table.columns.length, numericColumnCount, groupableColumnCount, focusConfidence: 'medium' }) + }, [activeEducationTable]) + const educationDataAnalysis = useMemo( + () => activeEducationTable ? analyzeEducationTableData(activeEducationTable) : null, + [activeEducationTable], + ) + const educationTablePageSize = educationAdaptiveRenderPlan.pageSize + const educationTableEngine = useReactTable({ + data: educationEngineData, + columns: educationEngineColumns, + state: { globalFilter: educationTableQuery, sorting: educationEngineSorting, pagination: { pageIndex: educationTablePageIndex, pageSize: educationTablePageSize } }, + globalFilterFn: (row, _columnId, filterValue) => { + const query = String(filterValue || '').trim().toLocaleLowerCase('zh-CN') + return !query || row.original.cells.some((cell) => cell.toLocaleLowerCase('zh-CN').includes(query)) + }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + const educationGroupingColumnId = educationTableEngineActive ? activeEducationTable?.columns[Math.min(educationTableGroupColumn, Math.max(0, (activeEducationTable?.columns.length || 1) - 1))]?.columnId : undefined + const educationGroupingState = useMemo(() => educationGroupingColumnId ? [educationGroupingColumnId] : [], [educationGroupingColumnId]) + const educationGroupingEngine = useReactTable({ + data: educationEngineData, + columns: educationEngineColumns, + state: { globalFilter: educationTableQuery, grouping: educationGroupingState }, + globalFilterFn: (row, _columnId, filterValue) => { + const query = String(filterValue || '').trim().toLocaleLowerCase('zh-CN') + return !query || row.original.cells.some((cell) => cell.toLocaleLowerCase('zh-CN').includes(query)) + }, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getGroupedRowModel: getGroupedRowModel(), + }) + const filteredEducationRowCount = educationTableEngine.getPrePaginationRowModel().rows.length + const educationTablePageCount = Math.max(1, Math.ceil(filteredEducationRowCount / educationTablePageSize)) + const visibleEducationRows = educationTableEngine.getRowModel().rows.map((row) => row.original) + useEffect(() => { + setEducationTablePageIndex(0) + }, [activeEducationTable?.tableId, educationTableQuery, educationTableSortColumn, educationTableSortDirection, educationTablePageSize]) + useEffect(() => { + setEducationTablePageIndex((current) => Math.min(current, educationTablePageCount - 1)) + }, [educationTablePageCount]) + const educationGroupBuckets = (() => { + if (!educationTableEngineActive || !activeEducationTable) return [] as Array<{ label: string; rows: EducationTableRow[]; measureTotal: number }> + const measureIndex = Math.min(educationTableMeasureColumn, Math.max(0, activeEducationTable.columns.length - 1)) + const grouped = educationGroupingEngine.getGroupedRowModel().rows.map((groupRow) => { + const rows = groupRow.subRows.map((row) => row.original) + const rawLabel = educationGroupingColumnId ? groupRow.getValue(educationGroupingColumnId) : '' + return { + label: String(rawLabel || '').trim() || '未分类', + rows, + measureTotal: rows.reduce((sum, row) => sum + (educationNumericValue(row.cells[measureIndex] || '') || 0), 0), + } + }) + const sorted = grouped.sort((left, right) => right.rows.length - left.rows.length || left.label.localeCompare(right.label, 'zh-CN')) + if (sorted.length <= 12) return sorted + const shown = sorted.slice(0, 11) + const remainder = sorted.slice(11) + shown.push({ label: `其他 ${remainder.length} 类`, rows: remainder.flatMap((bucket) => bucket.rows), measureTotal: remainder.reduce((sum, bucket) => sum + bucket.measureTotal, 0) }) + return shown + })() + const educationTableOverview = (() => { + if (!educationTableEngineActive || !activeEducationTable) return { filledCells: 0, totalCells: 0, fillRate: 0, numericCells: 0, measureTotal: 0, measureAverage: 0, measureMaximum: 0, uniqueGroups: 0 } + const totalCells = activeEducationTable.rows.length * activeEducationTable.columns.length + const filledCells = activeEducationTable.rows.reduce((sum, row) => sum + row.cells.filter((cell) => cell.trim()).length, 0) + const measureIndex = Math.min(educationTableMeasureColumn, Math.max(0, activeEducationTable.columns.length - 1)) + const values = activeEducationTable.rows.map((row) => educationNumericValue(row.cells[measureIndex] || '')).filter((value): value is number => value !== null) + return { + filledCells, + totalCells, + fillRate: totalCells ? Math.round((filledCells / totalCells) * 100) : 0, + numericCells: values.length, + measureTotal: values.reduce((sum, value) => sum + value, 0), + measureAverage: values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0, + measureMaximum: values.length ? Math.max(...values) : 0, + uniqueGroups: educationGroupBuckets.length, + } + })() + const educationSemanticFocus = educationTableEngineActive && activeEducationTable ? inferEducationSemanticFocus(activeEducationTable, Math.min(educationTableMeasureColumn, Math.max(0, activeEducationTable.columns.length - 1))) : null + const renderEducationDocuments = () => ( +
+
+ +
EDUCATION / DOCUMENTS教育文档
+
+
+
+ +
+ {activeEducationDocument ? <> +
+ {educationDocumentMode === 'edit' ? setActiveEducationDocument((current) => current ? { ...current, title: event.target.value } : current)}/> :
{activeEducationDocument.title}修订 {activeEducationDocument.revision} · {new Date(activeEducationDocument.updatedAtUnixMs).toLocaleString('zh-CN')}
} +
{educationDocumentMode === 'edit' ? <> : }
{educationDocumentSettingsOpen &&
}
+
+ {educationDocumentMode === 'edit' ?
+ 正在装载文档引擎…
}> + setActiveEducationDocument((current) => current ? { ...current, body } : current)}/> + +
{activeEducationDocument.body.replace(/\s/g, '').length.toLocaleString('zh-CN')} 字当前修改尚未保存
+
:
教育文档

{activeEducationDocument.title}

{activeEducationDocument.body.replace(/\s/g, '').length.toLocaleString('zh-CN')} 字 · 更新于 {new Date(activeEducationDocument.updatedAtUnixMs).toLocaleString('zh-CN')}

} + :

建立第一篇教育文档

可从空白开始,也可使用正式教学模板建立计划、纪要与复盘。

} + + +
+ ) + + const renderEducationImportReceipt = () => { + if (!educationImportOutcome) return null + const receipt = educationImportOutcome.importReceipt + const assistance = educationImportOutcome.assistanceReceipt + return + } + + const renderEducationBoard = () => { + if (!activeEducationTable) return null + const groupIndex = Math.min(educationTableGroupColumn, Math.max(0, activeEducationTable.columns.length - 1)) + const detailColumns = activeEducationTable.columns + .map((column, index) => ({ column, index })) + .filter(({ column, index }) => index !== groupIndex && !isSensitiveEducationColumn(column.title)) + .slice(0, 4) + return
+ {educationGroupBuckets.map((bucket) =>
+
{bucket.label}{bucket.rows.length} 条
{activeEducationTable.columns[groupIndex]?.title || '分类字段'}
+
{bucket.rows.map((row) =>
+ {detailColumns.map(({ column, index }) =>

{column.title || `字段 ${index + 1}`}{row.cells[index] || '—'}

)} + +
)}
+
)} + {!educationGroupBuckets.length &&

当前筛选下没有可分组的数据。

} +
+ } + + const renderEducationDashboard = () => { + if (!activeEducationTable) return null + const measureIndex = Math.min(educationTableMeasureColumn, Math.max(0, activeEducationTable.columns.length - 1)) + const measureTitle = activeEducationTable.columns[measureIndex]?.title || '统计字段' + const maxGroupCount = Math.max(1, ...educationGroupBuckets.map((bucket) => bucket.rows.length)) + return
+ {educationSemanticFocus &&
+
本地基础算法识别的首要关注点

{educationSemanticFocus.title}

{educationSemanticFocus.reason}。当前以“{measureTitle}”计算;你可以在上方改选统计字段。

+
{measureTitle}合计{educationTableOverview.measureTotal.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
平均 {educationTableOverview.measureAverage.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}最高 {educationTableOverview.measureMaximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
+
} + +
+
数据记录{filteredEducationRowCount.toLocaleString('zh-CN')}当前筛选 / {activeEducationTable.rows.length} 总行
+
字段数量{activeEducationTable.columns.length}同一份原生数据
+
内容完整度{educationTableOverview.fillRate}%{educationTableOverview.filledCells} / {educationTableOverview.totalCells} 单元格有值
+
{measureTitle}合计{educationTableOverview.measureTotal.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}{educationTableOverview.numericCells} 个可计算值
+
+
+
动态分类对比

按“{activeEducationTable.columns[educationTableGroupColumn]?.title || '分类字段'}”查看记录分布

{educationTableOverview.uniqueGroups} 个分类
+
{educationGroupBuckets.map((bucket) =>
{bucket.label}
{bucket.rows.length}{measureTitle}:{bucket.measureTotal.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
)}
+
+
图形由当前原生表格实时计算;切换分类字段或统计字段后立即重绘,不生成另一份数据副本。
+
+ } + + const renderEducationTables = () => ( +
+
+ +
EDUCATION / TABLE DATA教育表格数据
+
+
+ {renderEducationImportReceipt()} +
+ +
+ {activeEducationTable ? <> +
+ setActiveEducationTable((current) => current ? { ...current, title: event.target.value } : current)}/> +
修订 {activeEducationTable.revision}{educationTableView !== 'workbook' && <>}
{educationTableSettingsOpen &&
}
+
+
+ +
+ {activeEducationTable.columns.some((column) => isSensitiveEducationColumn(column.title)) && } + {(educationTableView === 'board' || educationTableView === 'dashboard') && } + {educationTableView === 'dashboard' && } + {(educationTableView === 'grid' || educationTableView === 'cards') && <>} +
+ {educationTableView === 'workbook' ? (activeEducationTable.columns.some((column) => isSensitiveEducationColumn(column.title)) && !educationSensitiveVisible ?
敏感字段当前已隐藏

真实工作表引擎不会用遮罩值覆盖原始数据。显示敏感字段后可在本机工作表中编辑,或切换到“数据表”继续遮罩查看。

: 正在启动频道内置工作表引擎…
}>) : educationTableView === 'grid' ?
+ + {activeEducationTable.columns.map((column, columnIndex) => )} + {visibleEducationRows.map((row, visibleRowIndex) => { const displayRowIndex = educationTablePageIndex * educationTablePageSize + visibleRowIndex + 1; const rowIndex = activeEducationTable.rows.findIndex((currentRow) => currentRow.rowId === row.rowId); return {row.cells.map((cell, cellIndex) => { const sensitive = isSensitiveEducationColumn(activeEducationTable.columns[cellIndex]?.title || ''); return })} })} +
#
setActiveEducationTable((current) => current ? { ...current, columns: current.columns.map((item, index) => index === columnIndex ? { ...item, title: event.target.value } : item) } : current)}/>
{displayRowIndex} { if (pasteEducationTableGrid(rowIndex, cellIndex, event.clipboardData.getData('text/plain'))) event.preventDefault() }} onChange={(event) => setActiveEducationTable((current) => current ? { ...current, rows: current.rows.map((currentRow) => currentRow.rowId === row.rowId ? { ...currentRow, cells: currentRow.cells.map((value, currentCellIndex) => currentCellIndex === cellIndex ? event.target.value : value) } : currentRow) } : current)}/>
+ {!visibleEducationRows.length &&

{activeEducationTable.rows.length ? '没有匹配当前筛选的数据。' : '这张表格还没有数据行。'}

{!activeEducationTable.rows.length && }
} +
: educationTableView === 'cards' ?
{visibleEducationRows.map((row, visibleRowIndex) => { const titleSensitive = isSensitiveEducationColumn(activeEducationTable.columns[0]?.title || ''); return
{visibleRowIndex + 1} setActiveEducationTable((current) => current ? { ...current, rows: current.rows.map((currentRow) => currentRow.rowId === row.rowId ? { ...currentRow, cells: currentRow.cells.map((value, index) => index === 0 ? event.target.value : value) } : currentRow) } : current)}/>
{activeEducationTable.columns.slice(1).map((column, offset) => { const cellIndex = offset + 1; const sensitive = isSensitiveEducationColumn(column.title); const label = `卡片 ${visibleRowIndex + 1} ${column.title || `字段 ${cellIndex + 1}`}${sensitive ? '(敏感字段)' : ''}`; return