// SPDX-License-Identifier: AGPL-3.0-or-later //! 教育表格的双向动态转译层。 //! //! 外部文件只作为只读输入。解析完成后立即转换为 HoloLake 原生列/行结构并进入 //! 当前登录账号的 SQLite 空间;WebView 不接触任意本机路径。导出则从原生结构生成 //! 人类选择的交换格式,不把 Office 文档模型带进运行内核。 use crate::education_workspace::{ education_workspace_database, import_table_batch_at, validate_table_data, EducationImportRegistration, EducationTableColumn, EducationTableRow, ImportedEducationTable, }; use calamine::{open_workbook_auto, Data, Reader}; use encoding_rs::GBK; use ring::digest::{digest, SHA256}; use rust_xlsxwriter::{Color, Format, Workbook}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; use uuid::Uuid; const TRANSLATOR_SCHEMA: &str = "hololake.education-table-translator/v1"; const NATIVE_TABLE_SCHEMA: &str = "hololake.education-table/v1"; const IMPORT_ADAPTER: &str = "EDU-TABLE-IMPORT-ADAPTER/v1"; const EXPORT_ADAPTER: &str = "EDU-TABLE-EXPORT-ADAPTER/v1"; const MAX_IMPORT_FILE_BYTES: u64 = 25 * 1024 * 1024; const MAX_TABLE_COLUMNS: usize = 30; const MAX_TABLE_ROWS: usize = 1_000; const MAX_CELL_BYTES: usize = 10_000; const MAX_TITLE_BYTES: usize = 300; const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001"; const ADAPTER: &str = "education-workbench-v1"; fn require_active(app: &AppHandle) -> Result<(), String> { crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationTableImportItem { pub table_id: String, pub title: String, pub sheet_name: String, pub column_count: usize, pub row_count: usize, pub leading_rows_ignored: usize, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationTableImportReceipt { pub schema: &'static str, pub state: &'static str, pub adapter_id: &'static str, pub import_id: String, pub source_format: String, pub source_filename: String, pub source_sha256: String, pub source_bytes: u64, pub native_schema: &'static str, pub content_profile: EducationContentProfile, pub imported_tables: Vec, pub imported_at_unix_ms: u128, pub source_preserved_read_only: bool, pub truncated: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationContentProfile { pub schema: &'static str, pub state: &'static str, pub detected_family: String, pub recognition_mode: String, pub container_format: String, pub extension_matches_container: bool, pub page_kind: String, pub page_count: usize, pub non_empty_page_count: usize, pub total_data_rows: usize, pub total_columns: usize, pub pages: Vec, pub routing: Vec, pub unresolved_signals: Vec, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationImportOutcome { pub state: String, pub import_receipt: Option, pub assistance_receipt: Option, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationRecognitionAssistanceReceipt { pub schema: &'static str, pub state: String, pub request_id: String, pub title: String, pub message: String, pub source_filename: String, pub detected_container: String, pub reason_code: String, pub model_assistance_eligible: bool, pub model_api_state: &'static str, pub model_api_slot: &'static str, pub requires_explicit_file_consent: bool, pub source_preserved_read_only: bool, pub available_learning_scopes: Vec<&'static str>, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationRecognitionCapability { pub schema: &'static str, pub state: &'static str, pub deterministic_adapter_ids: Vec<&'static str>, pub model_api_slot: &'static str, pub model_api_state: &'static str, pub provider_binding: &'static str, pub secret_storage_requirement: &'static str, pub file_transfer_default: &'static str, pub rule_update_flow: Vec<&'static str>, pub learning_scopes: Vec<&'static str>, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationContentPageProfile { pub page_name: String, pub state: String, pub used_rows: usize, pub used_columns: usize, pub header_row_index: Option, pub header_confidence: String, pub data_rows: usize, pub text_cells: usize, pub numeric_cells: usize, pub boolean_cells: usize, pub date_cells: usize, pub formula_cells: usize, pub structure_kind: String, pub focus_state: String, pub focus_title: String, pub primary_measure_column: Option, pub secondary_measure_columns: Vec, pub focus_confidence: String, pub focus_reasons: Vec, } #[derive(Clone, Debug)] struct EducationSemanticFocus { state: String, title: String, primary_measure_column: Option, secondary_measure_columns: Vec, confidence: String, reasons: Vec, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationContentRoute { pub page_name: String, pub source_structure: String, pub native_kind: String, pub target_module: String, pub decision: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExportEducationTableInput { pub table: EducationTableExportDraft, pub format: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EducationTableExportDraft { pub table_id: String, pub title: String, pub columns: Vec, pub rows: Vec, pub revision: i64, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EducationTableExportReceipt { pub schema: &'static str, pub state: &'static str, pub adapter_id: &'static str, pub table_id: String, pub table_revision: i64, pub target_format: String, pub target_filename: String, pub bytes: u64, pub row_count: usize, pub column_count: usize, pub exported_at_unix_ms: u128, } #[derive(Clone, Debug)] struct ParsedSheet { sheet_name: String, leading_rows_ignored: usize, table: ImportedEducationTable, } #[derive(Clone, Debug)] struct ParseOutcome { source_format: String, content_profile: EducationContentProfile, parsed_sheets: Vec, } #[derive(Clone, Copy, Debug, Default)] struct CellCounts { text: usize, numeric: usize, boolean: usize, date: usize, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct NativeEducationTableFile { schema: String, title: String, columns: Vec, rows: Vec, } pub async fn import_education_tables_from_dialog( app: AppHandle, ) -> Result, String> { require_active(&app)?; let selected = app .dialog() .file() .set_title("导入外部表格并转为 HoloLake 原生格式") .add_filter( "表格文件", &["xlsx", "xls", "xlsm", "xlsb", "ods", "csv", "tsv", "json"], ) .blocking_pick_file(); let Some(selected) = selected else { return Ok(None); }; let path = selected .into_path() .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_PATH_INVALID: {error}"))?; let database = education_workspace_database(&app)?; tauri::async_runtime::spawn_blocking(move || match import_tables_from_path(&database, &path) { Ok(receipt) => Ok(EducationImportOutcome { state: "STAGED_UNASSIGNED".into(), import_receipt: Some(receipt), assistance_receipt: None, }), Err(error) if is_human_assistance_case(&error) => Ok(EducationImportOutcome { state: "NEEDS_HUMAN_DECISION".into(), import_receipt: None, assistance_receipt: Some(assistance_receipt_for(&path, &error)), }), Err(error) => Err(error), }) .await .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_JOIN_FAILED: {error}"))? .map(Some) } pub fn get_education_recognition_capability( app: AppHandle, ) -> Result { require_active(&app)?; Ok(EducationRecognitionCapability { schema: "hololake.education-recognition-capability/v1", state: "DETERMINISTIC_READY_MODEL_SLOT_UNBOUND", deterministic_adapter_ids: vec![IMPORT_ADAPTER, EXPORT_ADAPTER], model_api_slot: "HOLOLAKE_MODEL_RECOGNITION_API/v1", model_api_state: "NOT_CONFIGURED", provider_binding: "USER_SELECTED_PROVIDER", secret_storage_requirement: "OPERATING_SYSTEM_SECRET_STORE", file_transfer_default: "DENY_UNTIL_EXPLICIT_PER_FILE_CONSENT", rule_update_flow: vec![ "MODEL_PROPOSES_CANDIDATE", "LOCAL_VALIDATION", "HUMAN_CONFIRMATION", "VERSIONED_RULE_INSTALL", ], learning_scopes: vec!["PRIVATE_ONLY", "SHARE_ANONYMIZED_RULE"], }) } pub async fn export_education_table_to_dialog( app: AppHandle, input: ExportEducationTableInput, ) -> Result, String> { require_active(&app)?; validate_export_draft(&input.table)?; let format = normalized_export_format(&input.format)?; let extension = match format.as_str() { "XLSX" => "xlsx", "CSV" => "csv", "TSV" => "tsv", "HOLOLAKE_NATIVE" => "holotable.json", _ => return Err("HOLOLAKE_EDUCATION_EXPORT_FORMAT_UNSUPPORTED".into()), }; let filename = format!("{}.{}", safe_filename(&input.table.title), extension); let selected = app .dialog() .file() .set_title("导出教育表格") .set_file_name(filename) .add_filter(export_filter_label(&format), &[extension]) .blocking_save_file(); let Some(selected) = selected else { return Ok(None); }; let path = selected .into_path() .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_PATH_INVALID: {error}"))?; tauri::async_runtime::spawn_blocking(move || export_table_to_path(&path, &format, input.table)) .await .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_JOIN_FAILED: {error}"))? .map(Some) } fn import_tables_from_path( database: &Path, source: &Path, ) -> Result { let metadata = fs::metadata(source) .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_UNREADABLE: {error}"))?; if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_IMPORT_FILE_BYTES { return Err("HOLOLAKE_EDUCATION_IMPORT_FILE_BOUNDS_INVALID".into()); } let source_filename = source .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| "HOLOLAKE_EDUCATION_IMPORT_FILENAME_INVALID".to_string())? .to_string(); let source_bytes = fs::read(source) .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_UNREADABLE: {error}"))?; let source_sha256 = hex_digest(&source_bytes); let outcome = parse_source_file(source, &source_bytes)?; if outcome.parsed_sheets.is_empty() { return Err("HOLOLAKE_EDUCATION_IMPORT_EMPTY".into()); } let imported_at_unix_ms = now_ms(); let profile_json = serde_json::to_string(&outcome.content_profile) .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_PROFILE_INVALID: {error}"))?; let (import_id, imported) = import_table_batch_at( database, outcome .parsed_sheets .iter() .map(|sheet| sheet.table.clone()) .collect(), EducationImportRegistration { source_filename: source_filename.clone(), source_format: outcome.source_format.clone(), source_sha256: source_sha256.clone(), profile_json, imported_at_unix_ms: imported_at_unix_ms as i64, }, )?; let imported_tables = outcome .parsed_sheets .into_iter() .zip(imported) .map(|(sheet, table)| EducationTableImportItem { table_id: table.table_id, title: table.title, sheet_name: sheet.sheet_name, column_count: table.columns.len(), row_count: table.rows.len(), leading_rows_ignored: sheet.leading_rows_ignored, }) .collect(); Ok(EducationTableImportReceipt { schema: TRANSLATOR_SCHEMA, state: "STAGED_UNASSIGNED", adapter_id: IMPORT_ADAPTER, import_id, source_format: outcome.source_format, source_filename, source_sha256, source_bytes: metadata.len(), native_schema: NATIVE_TABLE_SCHEMA, content_profile: outcome.content_profile, imported_tables, imported_at_unix_ms, source_preserved_read_only: true, truncated: false, }) } fn parse_source_file(source: &Path, bytes: &[u8]) -> Result { let filename = source .file_name() .and_then(|value| value.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if filename.ends_with(".holotable.json") { let sheet = parse_native_table(bytes)?; return Ok(outcome_from_parsed( "HOLOLAKE_NATIVE", "NATIVE_JSON", true, vec![sheet], Vec::new(), )); } let extension = source .extension() .and_then(|value| value.to_str()) .unwrap_or_default() .to_ascii_lowercase(); let detected_container = detect_container(bytes); match extension.as_str() { "csv" => parse_delimited(bytes, b',', source).map(|sheet| { outcome_from_parsed("CSV", "DELIMITED_TEXT", true, vec![sheet], Vec::new()) }), "tsv" => parse_delimited(bytes, b'\t', source).map(|sheet| { outcome_from_parsed("TSV", "DELIMITED_TEXT", true, vec![sheet], Vec::new()) }), "xlsx" | "xls" | "xlsm" | "xlsb" | "ods" => parse_workbook( source, &detected_workbook_format(&extension, &detected_container), &detected_container, extension_matches_container(&extension, &detected_container), ), _ => Err("HOLOLAKE_EDUCATION_IMPORT_FORMAT_UNSUPPORTED".into()), } } fn parse_workbook( source: &Path, detected_format: &str, detected_container: &str, extension_matches: bool, ) -> Result { let mut workbook = open_workbook_auto(source) .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_WORKBOOK_INVALID: {error}"))?; let stem = file_stem(source); let sheet_names = workbook.sheet_names().to_vec(); let mut parsed = Vec::new(); let mut profiles = Vec::new(); for sheet_name in sheet_names { let range = workbook .worksheet_range(&sheet_name) .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_SHEET_INVALID: {error}"))?; let formula_range = workbook.worksheet_formula(&sheet_name).ok(); let formula_cells = formula_range .as_ref() .map(|formulas| { formulas .rows() .flat_map(|row| row.iter()) .filter(|formula| !formula.trim().is_empty()) .count() }) .unwrap_or(0); let cell_counts = workbook_cell_counts(range.rows().flat_map(|row| row.iter())); let matrix = range .rows() .map(|row| row.iter().map(cell_text).collect::>()) .collect::>(); if let Some((table, leading_rows_ignored)) = matrix_to_native_table(matrix, &workbook_table_title(&stem, &sheet_name), true)? { profiles.push(profile_for_imported_sheet( &sheet_name, &table, leading_rows_ignored, cell_counts, formula_cells, )); parsed.push(ParsedSheet { sheet_name, leading_rows_ignored, table, }); } else { profiles.push(EducationContentPageProfile { page_name: sheet_name, state: "EMPTY_SKIPPED".into(), used_rows: 0, used_columns: 0, header_row_index: None, header_confidence: "NONE".into(), data_rows: 0, text_cells: 0, numeric_cells: 0, boolean_cells: 0, date_cells: 0, formula_cells, structure_kind: "EMPTY".into(), focus_state: "NO_CONTENT".into(), focus_title: "空页".into(), primary_measure_column: None, secondary_measure_columns: Vec::new(), focus_confidence: "NONE".into(), focus_reasons: vec!["页面没有可分析的数据".into()], }); } } Ok(outcome_from_parsed( detected_format, detected_container, extension_matches, parsed, profiles, )) } fn parse_delimited(bytes: &[u8], delimiter: u8, source: &Path) -> Result { let decoded = decode_delimited_text(bytes)?; let mut reader = csv::ReaderBuilder::new() .has_headers(false) .flexible(true) .delimiter(delimiter) .from_reader(decoded.as_bytes()); let mut matrix = Vec::new(); for record in reader.records() { let record = record .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_DELIMITED_INVALID: {error}"))?; matrix.push(record.iter().map(ToString::to_string).collect()); if matrix.len() > MAX_TABLE_ROWS + 32 { return Err("HOLOLAKE_EDUCATION_IMPORT_ROW_LIMIT_EXCEEDED".into()); } } let title = file_stem(source); let (table, leading_rows_ignored) = matrix_to_native_table(matrix, &title, false)? .ok_or_else(|| "HOLOLAKE_EDUCATION_IMPORT_EMPTY".to_string())?; Ok(ParsedSheet { sheet_name: title, leading_rows_ignored, table, }) } fn parse_native_table(bytes: &[u8]) -> Result { let native: NativeEducationTableFile = serde_json::from_slice(bytes) .map_err(|error| format!("HOLOLAKE_EDUCATION_IMPORT_NATIVE_INVALID: {error}"))?; if native.schema != NATIVE_TABLE_SCHEMA { return Err("HOLOLAKE_EDUCATION_IMPORT_NATIVE_SCHEMA_UNSUPPORTED".into()); } let columns = native .columns .into_iter() .map(|column| EducationTableColumn { column_id: format!("COL-{}", Uuid::new_v4()), title: column.title, }) .collect::>(); let rows = native .rows .into_iter() .map(|row| EducationTableRow { row_id: format!("ROW-{}", Uuid::new_v4()), cells: row.cells, }) .collect::>(); validate_table_data(&columns, &rows)?; Ok(ParsedSheet { sheet_name: "HoloLake 原生表格".into(), leading_rows_ignored: 0, table: ImportedEducationTable { title: native.title, columns, rows, }, }) } fn outcome_from_parsed( source_format: &str, container_format: &str, extension_matches_container: bool, parsed_sheets: Vec, mut profiles: Vec, ) -> ParseOutcome { if profiles.is_empty() { profiles = parsed_sheets .iter() .map(|sheet| { let counts = inferred_cell_counts(&sheet.table); profile_for_imported_sheet( &sheet.sheet_name, &sheet.table, sheet.leading_rows_ignored, counts, 0, ) }) .collect(); } let routing = profiles .iter() .map(|page| EducationContentRoute { page_name: page.page_name.clone(), source_structure: page.structure_kind.clone(), native_kind: if page.state == "READY_TO_TRANSLATE" { "HOLOLAKE_TABLE".into() } else { "NONE".into() }, target_module: if page.state == "READY_TO_TRANSLATE" { "UNASSIGNED_CHANNEL_STAGING".into() } else { "NONE".into() }, decision: if page.state == "READY_TO_TRANSLATE" { "PROFILED_THEN_STAGED_PENDING_HUMAN_ROUTE".into() } else { "EMPTY_PAGE_SKIPPED_WITH_RECORD".into() }, }) .collect::>(); let non_empty_page_count = profiles .iter() .filter(|profile| profile.state == "READY_TO_TRANSLATE") .count(); let total_data_rows = profiles.iter().map(|profile| profile.data_rows).sum(); let total_columns = profiles.iter().map(|profile| profile.used_columns).sum(); ParseOutcome { source_format: source_format.into(), content_profile: EducationContentProfile { schema: "hololake.content-profile/v1", state: "PROFILED_BEFORE_WRITE", detected_family: "TABULAR_DATA".into(), recognition_mode: "DETERMINISTIC_LOCAL".into(), container_format: container_format.into(), extension_matches_container, page_kind: if profiles.len() > 1 { "WORKSHEET".into() } else { "TABLE_PAGE".into() }, page_count: profiles.len(), non_empty_page_count, total_data_rows, total_columns, pages: profiles, routing, unresolved_signals: profile_unresolved_signals(container_format), }, parsed_sheets, } } fn profile_unresolved_signals(container_format: &str) -> Vec { match container_format { "OOXML_WORKBOOK_ZIP" | "XLSB_WORKBOOK_ZIP" | "OLE_COMPOUND" | "ODS_ZIP" => vec![ "SOURCE_VISUAL_FORMATTING_IS_PRESENTATION_ONLY_NOT_NATIVE_DATA".into(), "FORMULAS_ARE_IMPORTED_AS_LAST_STORED_VALUES_UNLESS_EXPLICITLY_REBUILT".into(), ], _ => Vec::new(), } } fn is_human_assistance_case(error: &str) -> bool { error.starts_with("HOLOLAKE_EDUCATION_IMPORT_") && !error.starts_with("HOLOLAKE_EDUCATION_IMPORT_UNREADABLE") && !error.starts_with("HOLOLAKE_EDUCATION_IMPORT_PATH_INVALID") && !error.starts_with("HOLOLAKE_EDUCATION_IMPORT_FILENAME_INVALID") } fn assistance_receipt_for(source: &Path, reason: &str) -> EducationRecognitionAssistanceReceipt { let filename = source .file_name() .and_then(|value| value.to_str()) .unwrap_or("未命名文件") .to_string(); let bytes = fs::read(source).unwrap_or_default(); let detected_container = detect_container(&bytes); let safety_limit = reason.contains("LIMIT_EXCEEDED") || reason.contains("BOUNDS_INVALID") || reason.contains("TOO_LARGE"); let (title, message, eligible) = if safety_limit { ( "这个文件超出了当前模块的安全处理范围".to_string(), "HoloLake 已停止写入并保留原文件。请先拆分文件或减少单页行列;模型辅助不会绕过本机安全上限。".to_string(), false, ) } else { ( "当前版本还不能可靠识别这个文件".to_string(), "HoloLake 没有猜测内容,也没有写入模块。配置模型接口后,可在明确授权当前文件的前提下重新识别,并把新规则保存为仅自己使用或去隐私后共享。".to_string(), true, ) }; EducationRecognitionAssistanceReceipt { schema: "hololake.recognition-assistance-receipt/v1", state: if eligible { "MODEL_ASSISTANCE_AVAILABLE_AFTER_CONFIGURATION".into() } else { "LOCAL_INPUT_CHANGE_REQUIRED".into() }, request_id: format!("EDU-RECOGNITION-{}", Uuid::new_v4()), title, message, source_filename: filename, detected_container, reason_code: reason.to_string(), model_assistance_eligible: eligible, model_api_state: "NOT_CONFIGURED", model_api_slot: "HOLOLAKE_MODEL_RECOGNITION_API/v1", requires_explicit_file_consent: true, source_preserved_read_only: true, available_learning_scopes: vec!["PRIVATE_ONLY", "SHARE_ANONYMIZED_RULE"], } } fn profile_for_imported_sheet( sheet_name: &str, table: &ImportedEducationTable, leading_rows_ignored: usize, counts: CellCounts, formula_cells: usize, ) -> EducationContentPageProfile { let focus = infer_semantic_focus(table); EducationContentPageProfile { page_name: sheet_name.into(), state: "READY_TO_TRANSLATE".into(), used_rows: table.rows.len() + 1, used_columns: table.columns.len(), header_row_index: Some(leading_rows_ignored), header_confidence: if leading_rows_ignored > 0 { "DENSE_ROW_DETECTED".into() } else { "FIRST_NONEMPTY_ROW".into() }, data_rows: table.rows.len(), text_cells: counts.text, numeric_cells: counts.numeric, boolean_cells: counts.boolean, date_cells: counts.date, formula_cells, structure_kind: if table.columns.len() == 1 { "SINGLE_FIELD_LIST".into() } else { "RECTANGULAR_TABLE".into() }, focus_state: focus.state, focus_title: focus.title, primary_measure_column: focus.primary_measure_column, secondary_measure_columns: focus.secondary_measure_columns, focus_confidence: focus.confidence, focus_reasons: focus.reasons, } } fn infer_semantic_focus(table: &ImportedEducationTable) -> EducationSemanticFocus { let title = table.title.to_lowercase(); let compensation_context = contains_any(&title, &["稿费", "收入", "薪酬", "结算", "营收"]); let achievement_context = contains_any(&title, &["成绩", "考试", "测评", "学员"]); let attendance_context = contains_any(&title, &["考勤", "出勤", "签到"]); let mut scored = table .columns .iter() .enumerate() .filter_map(|(index, column)| { let name = column.title.trim(); if name.is_empty() || is_sensitive_header(name) { return None; } let normalized = name.to_lowercase().replace([' ', '_', '-'], ""); let mut score = semantic_measure_score(&normalized, compensation_context); let numeric_count = table .rows .iter() .filter(|row| { parse_semantic_number(row.cells.get(index).map(String::as_str).unwrap_or("")) .is_some() }) .count(); if !table.rows.is_empty() { score += ((numeric_count * 30) / table.rows.len()) as i32; } (score > 0).then(|| (score, name.to_string())) }) .collect::>(); scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); let primary = scored.first().cloned(); let focus_title = if compensation_context { "稿费收入".to_string() } else if achievement_context { "学习成绩".to_string() } else if attendance_context { "出勤情况".to_string() } else if let Some((_, column)) = &primary { column.clone() } else { "重点待选择".to_string() }; let Some((primary_score, primary_column)) = primary else { return EducationSemanticFocus { state: "NEEDS_HUMAN_SELECTION".into(), title: focus_title, primary_measure_column: None, secondary_measure_columns: Vec::new(), confidence: "LOW".into(), reasons: vec!["没有发现可可靠计算的重点字段,渲染层必须请人选择".into()], }; }; if primary_score < 70 { return EducationSemanticFocus { state: "NEEDS_HUMAN_SELECTION".into(), title: "重点待选择".into(), primary_measure_column: None, secondary_measure_columns: scored.into_iter().take(3).map(|(_, name)| name).collect(), confidence: "LOW".into(), reasons: vec!["字段语义和数值覆盖率不足以自动决定主指标".into()], }; } EducationSemanticFocus { state: "DETERMINISTIC_FOCUS_INFERRED".into(), title: focus_title.clone(), primary_measure_column: Some(primary_column.clone()), secondary_measure_columns: scored .into_iter() .skip(1) .take(3) .map(|(_, name)| name) .collect(), confidence: if primary_score >= 140 { "HIGH" } else { "MEDIUM" } .into(), reasons: vec![ format!("表名与页名语义指向“{focus_title}”"), format!("字段“{primary_column}”同时通过语义优先级与数值覆盖率评分"), ], } } fn semantic_measure_score(name: &str, compensation_context: bool) -> i32 { if contains_any( name, &["序号", "编号", "账号", "电话", "手机", "id", "日期", "时间"], ) { return -200; } let mut score = if contains_any(name, &["实际收入", "实收", "到账"]) { 170 } else if contains_any(name, &["当月收入", "本月收入", "稿费", "结算金额"]) { 160 } else if contains_any(name, &["累计收入", "总收入", "收入合计"]) { 150 } else if contains_any(name, &["收入", "营收", "薪酬", "金额", "合计", "总计"]) { 120 } else if contains_any( name, &[ "成绩", "分数", "得分", "数量", "人数", "课时", "时长", "成本", "预算", "进度", "完成率", ], ) { 90 } else { 0 }; if compensation_context && contains_any(name, &["收入", "稿费", "实收", "到账"]) { score += 35; } score } fn contains_any(value: &str, candidates: &[&str]) -> bool { candidates.iter().any(|candidate| value.contains(candidate)) } fn is_sensitive_header(value: &str) -> bool { contains_any( &value.to_lowercase(), &[ "密码", "口令", "密钥", "凭据", "secret", "token", "apikey", "accesskey", "credential", ], ) } fn parse_semantic_number(value: &str) -> Option { let normalized = value .trim() .replace([',', ',', ' ', '¥', '¥', '$', '€', '£'], "") .trim_end_matches('%') .to_string(); if normalized.is_empty() { return None; } normalized .parse::() .ok() .filter(|value| value.is_finite()) } fn workbook_cell_counts<'a>(cells: impl Iterator) -> CellCounts { let mut counts = CellCounts::default(); for cell in cells { match cell { Data::Int(_) | Data::Float(_) => counts.numeric += 1, Data::Bool(_) => counts.boolean += 1, Data::DateTime(_) | Data::DateTimeIso(_) | Data::DurationIso(_) => counts.date += 1, Data::String(value) if !value.trim().is_empty() => counts.text += 1, Data::Error(_) => counts.text += 1, Data::Empty | Data::String(_) => {} } } counts } fn inferred_cell_counts(table: &ImportedEducationTable) -> CellCounts { let mut counts = CellCounts { text: table.columns.len(), ..CellCounts::default() }; for cell in table.rows.iter().flat_map(|row| row.cells.iter()) { let value = cell.trim(); if value.is_empty() { continue; } if value.parse::().is_ok() { counts.numeric += 1; } else if matches!(value.to_ascii_lowercase().as_str(), "true" | "false") { counts.boolean += 1; } else if looks_like_iso_date(value) { counts.date += 1; } else { counts.text += 1; } } counts } fn looks_like_iso_date(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() >= 10 && bytes[0..4].iter().all(u8::is_ascii_digit) && matches!(bytes[4], b'-' | b'/') && bytes[5..7].iter().all(u8::is_ascii_digit) && matches!(bytes[7], b'-' | b'/') && bytes[8..10].iter().all(u8::is_ascii_digit) } fn detect_container(bytes: &[u8]) -> String { if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) { return "OLE_COMPOUND".into(); } if bytes.starts_with(b"PK\x03\x04") { if contains_bytes(bytes, b"xl/workbook.bin") { return "XLSB_WORKBOOK_ZIP".into(); } if contains_bytes(bytes, b"xl/workbook.xml") { return "OOXML_WORKBOOK_ZIP".into(); } if contains_bytes(bytes, b"content.xml") && contains_bytes(bytes, b"mimetype") { return "ODS_ZIP".into(); } return "ZIP_CONTAINER".into(); } if bytes .iter() .take(512) .all(|byte| *byte == 0 || *byte == 9 || *byte == 10 || *byte == 13 || *byte >= 0x20) { return "TEXT_STREAM".into(); } "UNKNOWN_BINARY".into() } fn detected_workbook_format(extension: &str, container: &str) -> String { match container { "OLE_COMPOUND" => "XLS".into(), "XLSB_WORKBOOK_ZIP" => "XLSB".into(), "ODS_ZIP" => "ODS".into(), "OOXML_WORKBOOK_ZIP" if extension == "xlsm" => "XLSM".into(), "OOXML_WORKBOOK_ZIP" => "XLSX".into(), _ => extension.to_ascii_uppercase(), } } fn extension_matches_container(extension: &str, container: &str) -> bool { match extension { "xls" => container == "OLE_COMPOUND", "xlsb" => container == "XLSB_WORKBOOK_ZIP", "ods" => container == "ODS_ZIP", "xlsx" | "xlsm" => container == "OOXML_WORKBOOK_ZIP", _ => false, } } fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { !needle.is_empty() && haystack .windows(needle.len()) .any(|window| window == needle) } fn matrix_to_native_table( matrix: Vec>, title: &str, detect_header: bool, ) -> Result, String> { let nonempty_rows = matrix .iter() .enumerate() .filter(|(_, row)| row.iter().any(|cell| !cell.trim().is_empty())) .map(|(index, _)| index) .collect::>(); let Some(&first_nonempty) = nonempty_rows.first() else { return Ok(None); }; let header_index = if detect_header { let mut best_index = first_nonempty; let mut best_score = 0; for index in nonempty_rows.iter().copied().take(10) { let score = matrix[index] .iter() .filter(|cell| !cell.trim().is_empty()) .count(); if score > best_score { best_index = index; best_score = score; } } best_index } else { first_nonempty }; let last_row = *nonempty_rows.last().unwrap_or(&header_index); let first_column = matrix[header_index..=last_row] .iter() .filter_map(|row| row.iter().position(|cell| !cell.trim().is_empty())) .min() .unwrap_or(0); let last_column = matrix[header_index..=last_row] .iter() .filter_map(|row| row.iter().rposition(|cell| !cell.trim().is_empty())) .max() .unwrap_or(first_column); let column_count = last_column.saturating_sub(first_column) + 1; if column_count == 0 || column_count > MAX_TABLE_COLUMNS { return Err("HOLOLAKE_EDUCATION_IMPORT_COLUMN_LIMIT_EXCEEDED".into()); } let header = &matrix[header_index]; let mut seen_headers: HashMap = HashMap::new(); let mut columns = Vec::with_capacity(column_count); for offset in 0..column_count { let source_title = header .get(first_column + offset) .map(|value| value.trim()) .unwrap_or_default(); let base = if source_title.is_empty() { format!("字段 {}", offset + 1) } else { source_title.to_string() }; if base.len() > MAX_TITLE_BYTES { return Err("HOLOLAKE_EDUCATION_IMPORT_HEADER_TOO_LARGE".into()); } let count = seen_headers.entry(base.clone()).or_insert(0); *count += 1; let title = if *count == 1 { base } else { format!("{} ({})", base, count) }; columns.push(EducationTableColumn { column_id: format!("COL-{}", Uuid::new_v4()), title, }); } let mut rows = Vec::new(); for source_row in matrix.iter().take(last_row + 1).skip(header_index + 1) { let cells = (0..column_count) .map(|offset| { source_row .get(first_column + offset) .cloned() .unwrap_or_default() }) .collect::>(); if cells.iter().all(|cell| cell.trim().is_empty()) { continue; } if cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES) { return Err("HOLOLAKE_EDUCATION_IMPORT_CELL_TOO_LARGE".into()); } rows.push(EducationTableRow { row_id: format!("ROW-{}", Uuid::new_v4()), cells, }); if rows.len() > MAX_TABLE_ROWS { return Err("HOLOLAKE_EDUCATION_IMPORT_ROW_LIMIT_EXCEEDED".into()); } } let table = ImportedEducationTable { title: bounded_title(title)?, columns, rows, }; validate_table_data(&table.columns, &table.rows)?; Ok(Some((table, header_index))) } fn cell_text(cell: &Data) -> String { match cell { Data::DateTime(value) => value .as_datetime() .map(|datetime| { if datetime.and_utc().timestamp() % 86_400 == 0 { datetime.date().to_string() } else { datetime.to_string() } }) .unwrap_or_else(|| value.to_string()), Data::Empty => String::new(), _ => cell.to_string(), } } fn decode_delimited_text(bytes: &[u8]) -> Result { if let Some(stripped) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { return String::from_utf8(stripped.to_vec()) .map_err(|_| "HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); } if let Some(stripped) = bytes.strip_prefix(&[0xFF, 0xFE]) { if stripped.len() % 2 != 0 { return Err("HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); } let values = stripped .chunks_exact(2) .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect::>(); return String::from_utf16(&values) .map_err(|_| "HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); } if let Some(stripped) = bytes.strip_prefix(&[0xFE, 0xFF]) { if stripped.len() % 2 != 0 { return Err("HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); } let values = stripped .chunks_exact(2) .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) .collect::>(); return String::from_utf16(&values) .map_err(|_| "HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()); } if let Ok(value) = String::from_utf8(bytes.to_vec()) { return Ok(value); } let (decoded, _, had_errors) = GBK.decode(bytes); if had_errors { Err("HOLOLAKE_EDUCATION_IMPORT_TEXT_ENCODING_UNSUPPORTED".into()) } else { Ok(decoded.into_owned()) } } fn export_table_to_path( target: &Path, format: &str, table: EducationTableExportDraft, ) -> Result { validate_export_draft(&table)?; if let Some(parent) = target.parent() { if !parent.exists() { return Err("HOLOLAKE_EDUCATION_EXPORT_DIRECTORY_MISSING".into()); } } match format { "XLSX" => write_xlsx(target, &table)?, "CSV" => write_delimited(target, &table, b',')?, "TSV" => write_delimited(target, &table, b'\t')?, "HOLOLAKE_NATIVE" => write_native_table(target, &table)?, _ => return Err("HOLOLAKE_EDUCATION_EXPORT_FORMAT_UNSUPPORTED".into()), } let metadata = fs::metadata(target) .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_VERIFY_FAILED: {error}"))?; Ok(EducationTableExportReceipt { schema: TRANSLATOR_SCHEMA, state: "EXPORTED_FROM_NATIVE", adapter_id: EXPORT_ADAPTER, table_id: table.table_id, table_revision: table.revision, target_format: format.to_string(), target_filename: target .file_name() .and_then(|value| value.to_str()) .unwrap_or("教育表格") .to_string(), bytes: metadata.len(), row_count: table.rows.len(), column_count: table.columns.len(), exported_at_unix_ms: now_ms(), }) } fn write_xlsx(target: &Path, table: &EducationTableExportDraft) -> Result<(), String> { let mut workbook = Workbook::new(); let worksheet = workbook.add_worksheet(); worksheet .set_name("HoloLake 数据") .map_err(export_xlsx_error)?; let header = Format::new() .set_bold() .set_font_color(Color::RGB(0xF7EDC5)) .set_background_color(Color::RGB(0x111827)); for (column_index, column) in table.columns.iter().enumerate() { worksheet .write_string_with_format(0, column_index as u16, &column.title, &header) .map_err(export_xlsx_error)?; } for (row_index, row) in table.rows.iter().enumerate() { for (column_index, cell) in row.cells.iter().enumerate() { worksheet .write_string((row_index + 1) as u32, column_index as u16, cell) .map_err(export_xlsx_error)?; } } worksheet .set_freeze_panes(1, 0) .map_err(export_xlsx_error)?; worksheet.autofit(); workbook.save(target).map_err(export_xlsx_error) } fn write_delimited( target: &Path, table: &EducationTableExportDraft, delimiter: u8, ) -> Result<(), String> { let mut writer = csv::WriterBuilder::new() .delimiter(delimiter) .from_writer(Vec::new()); writer .write_record(table.columns.iter().map(|column| column.title.as_str())) .map_err(export_csv_error)?; for row in &table.rows { let protected = row .cells .iter() .map(|cell| protect_spreadsheet_formula(cell)) .collect::>(); writer.write_record(protected).map_err(export_csv_error)?; } writer .flush() .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_DELIMITED_FAILED: {error}"))?; let bytes = writer .into_inner() .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_DELIMITED_FAILED: {error}"))?; let mut with_bom = Vec::with_capacity(bytes.len() + 3); with_bom.extend_from_slice(&[0xEF, 0xBB, 0xBF]); with_bom.extend_from_slice(&bytes); fs::write(target, with_bom) .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_WRITE_FAILED: {error}")) } fn write_native_table(target: &Path, table: &EducationTableExportDraft) -> Result<(), String> { let native = NativeEducationTableFile { schema: NATIVE_TABLE_SCHEMA.into(), title: table.title.clone(), columns: table.columns.clone(), rows: table.rows.clone(), }; let bytes = serde_json::to_vec_pretty(&native) .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_NATIVE_FAILED: {error}"))?; fs::write(target, bytes) .map_err(|error| format!("HOLOLAKE_EDUCATION_EXPORT_WRITE_FAILED: {error}")) } fn validate_export_draft(table: &EducationTableExportDraft) -> Result<(), String> { if !table.table_id.starts_with("EDU-TABLE-") || table.revision < 1 { return Err("HOLOLAKE_EDUCATION_EXPORT_TABLE_INVALID".into()); } bounded_title(&table.title)?; validate_table_data(&table.columns, &table.rows) } fn normalized_export_format(value: &str) -> Result { let value = value.trim().to_ascii_uppercase(); if matches!(value.as_str(), "XLSX" | "CSV" | "TSV" | "HOLOLAKE_NATIVE") { Ok(value) } else { Err("HOLOLAKE_EDUCATION_EXPORT_FORMAT_UNSUPPORTED".into()) } } fn export_filter_label(format: &str) -> &'static str { match format { "XLSX" => "Excel 工作簿", "CSV" => "CSV 表格", "TSV" => "TSV 表格", "HOLOLAKE_NATIVE" => "HoloLake 原生表格", _ => "表格文件", } } fn protect_spreadsheet_formula(cell: &str) -> String { if cell .trim_start() .chars() .next() .is_some_and(|character| matches!(character, '=' | '+' | '-' | '@')) { format!("'{cell}") } else { cell.to_string() } } fn bounded_title(value: &str) -> Result { let title = value.trim(); if title.is_empty() || title.len() > MAX_TITLE_BYTES || title .chars() .any(|character| matches!(character, '\0' | '\r' | '\n')) { Err("HOLOLAKE_EDUCATION_IMPORT_TITLE_INVALID".into()) } else { Ok(title.to_string()) } } fn workbook_table_title(stem: &str, sheet_name: &str) -> String { let candidate = format!("{stem} · {sheet_name}"); if candidate.len() <= MAX_TITLE_BYTES { candidate } else if sheet_name.len() <= MAX_TITLE_BYTES { sheet_name.to_string() } else { "导入的教育表格".into() } } fn file_stem(path: &Path) -> String { path.file_stem() .and_then(|value| value.to_str()) .filter(|value| !value.trim().is_empty()) .unwrap_or("导入的教育表格") .to_string() } fn safe_filename(value: &str) -> String { let name = value .chars() .map(|character| { if matches!( character, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' ) || character.is_control() { '_' } else { character } }) .collect::(); let name = name.trim().trim_matches('.'); if name.is_empty() { "教育表格".into() } else { name.to_string() } } fn hex_digest(bytes: &[u8]) -> String { digest(&SHA256, bytes) .as_ref() .iter() .map(|byte| format!("{byte:02x}")) .collect() } fn export_xlsx_error(error: rust_xlsxwriter::XlsxError) -> String { format!("HOLOLAKE_EDUCATION_EXPORT_XLSX_FAILED: {error}") } fn export_csv_error(error: csv::Error) -> String { format!("HOLOLAKE_EDUCATION_EXPORT_DELIMITED_FAILED: {error}") } fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn csv_round_trip_preserves_chinese_and_blocks_formula_injection() { let directory = tempdir().unwrap(); let source = directory.path().join("学员.csv"); fs::write(&source, "姓名,状态\n冰朔,连载中\n危险,=cmd()\n").unwrap(); let database = directory.path().join("education.sqlite3"); let receipt = import_tables_from_path(&database, &source).unwrap(); assert_eq!(receipt.imported_tables.len(), 1); let imported = crate::education_workspace::read_table_at( &database, &receipt.imported_tables[0].table_id, ) .unwrap(); assert_eq!(imported.rows[0].cells, vec!["冰朔", "连载中"]); let export = directory.path().join("export.csv"); export_table_to_path( &export, "CSV", EducationTableExportDraft { table_id: imported.table_id, title: imported.title, columns: imported.columns, rows: imported.rows, revision: imported.revision, }, ) .unwrap(); let exported = fs::read_to_string(export).unwrap(); assert!(exported.contains("危险,'=cmd()")); } #[test] fn xlsx_import_uses_dense_header_and_exports_valid_workbook() { let directory = tempdir().unwrap(); let source = directory.path().join("稿费.xlsx"); let mut workbook = Workbook::new(); let worksheet = workbook.add_worksheet(); worksheet.write_string(0, 0, "稿费汇总").unwrap(); worksheet.write_string(2, 0, "作品").unwrap(); worksheet.write_string(2, 1, "月份").unwrap(); worksheet.write_string(2, 2, "金额").unwrap(); worksheet.write_string(3, 0, "测试作品").unwrap(); worksheet.write_string(3, 1, "2025-10").unwrap(); worksheet.write_number(3, 2, 1234.5).unwrap(); workbook.save(&source).unwrap(); let database = directory.path().join("education.sqlite3"); let receipt = import_tables_from_path(&database, &source).unwrap(); assert_eq!(receipt.imported_tables[0].leading_rows_ignored, 2); assert_eq!(receipt.imported_tables[0].column_count, 3); assert_eq!(receipt.imported_tables[0].row_count, 1); assert_eq!(receipt.content_profile.pages[0].focus_title, "稿费收入"); assert_eq!( receipt.content_profile.pages[0] .primary_measure_column .as_deref(), Some("金额") ); let imported = crate::education_workspace::read_table_at( &database, &receipt.imported_tables[0].table_id, ) .unwrap(); let target = directory.path().join("roundtrip.xlsx"); export_table_to_path( &target, "XLSX", EducationTableExportDraft { table_id: imported.table_id, title: imported.title, columns: imported.columns, rows: imported.rows, revision: imported.revision, }, ) .unwrap(); let mut roundtrip = open_workbook_auto(target).unwrap(); let range = roundtrip.worksheet_range("HoloLake 数据").unwrap(); assert_eq!(range.get_value((0, 0)).unwrap().to_string(), "作品"); assert_eq!(range.get_value((1, 2)).unwrap().to_string(), "1234.5"); } #[test] fn oversized_sheet_fails_instead_of_truncating() { let mut matrix = vec![(0..31).map(|index| format!("字段 {index}")).collect()]; matrix.push((0..31).map(|_| "值".to_string()).collect()); assert_eq!( matrix_to_native_table(matrix, "超限", false).unwrap_err(), "HOLOLAKE_EDUCATION_IMPORT_COLUMN_LIMIT_EXCEEDED" ); } #[test] fn ambiguous_table_keeps_the_focus_as_a_human_choice() { let table = ImportedEducationTable { title: "杂项记录".into(), columns: vec![ EducationTableColumn { column_id: "COL-A".into(), title: "内容甲".into(), }, EducationTableColumn { column_id: "COL-B".into(), title: "内容乙".into(), }, ], rows: vec![EducationTableRow { row_id: "ROW-A".into(), cells: vec!["甲".into(), "乙".into()], }], }; let focus = infer_semantic_focus(&table); assert_eq!(focus.state, "NEEDS_HUMAN_SELECTION"); assert_eq!(focus.primary_measure_column, None); } }